diff --git a/blog/sexp/sexp_components.py b/blog/sexp/sexp_components.py index 2a85485..8bbaa58 100644 --- a/blog/sexp/sexp_components.py +++ b/blog/sexp/sexp_components.py @@ -86,19 +86,21 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: ) # Admin link - from quart import url_for as qurl, g + from quart import url_for as qurl, g, request rights = ctx.get("rights") or {} has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) if has_admin: - hx_select = ctx.get("hx_select_search", "#main-panel") select_colours = ctx.get("select_colours", "") styles = ctx.get("styles") or {} nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "") admin_href = qurl("blog.post.admin.admin", slug=slug) + is_admin_page = "/admin" in request.path + sel_attr = ' aria-selected="true"' if is_admin_page else '' nav_parts.append( f'' ) diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index d686ec2..1fc1fb8 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -77,9 +77,23 @@ def register(): @bp.context_processor async def inject_root(): + from shared.infrastructure.fragments import fetch_fragment + + container_nav_html = "" + post_data = getattr(g, "post_data", None) + if post_data: + post_id = post_data["post"]["id"] + post_slug = post_data["post"]["slug"] + container_nav_html = await fetch_fragment("relations", "container-nav", params={ + "container_type": "page", + "container_id": str(post_id), + "post_slug": post_slug, + "exclude": "page->calendar", + }) return { "calendar": getattr(g, "calendar", None), + "container_nav_html": container_nav_html, } # ---------- Pages ---------- diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index 3c64958..a9c0e51 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -44,16 +44,14 @@ def register(): @bp.get("/") async def get(**kwargs): + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_slots_page, render_slots_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/slots/index.html", - ) + html = await render_slots_page(tctx) else: - - html = await render_template( - "_types/slots/_oob_elements.html", - ) + html = await render_slots_oob(tctx) return await make_response(html) diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py index 949dcf0..e9ca07a 100644 --- a/events/sexp/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -79,34 +79,31 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: def _post_nav_html(ctx: dict) -> str: - """Post desktop nav: calendar links + admin gear.""" - from quart import url_for + """Post desktop nav: calendar links + container nav (markets, etc.).""" + from quart import url_for, g calendars = ctx.get("calendars") or [] - rights = ctx.get("rights") or {} - is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) - hx_select = ctx.get("hx_select_search", "#main-panel") select_colours = ctx.get("select_colours", "") - post = ctx.get("post") or {} - slug = post.get("slug", "") + current_cal_slug = getattr(g, "calendar_slug", None) parts = [] for cal in calendars: cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "") cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "") href = url_for("calendars.calendar.get", calendar_slug=cal_slug) + is_sel = (cal_slug == current_cal_slug) parts.append(sexp( - '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc)', + '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc :is-selected sel)', h=href, l=cal_name, sc=select_colours, + sel=is_sel, )) - if is_admin: - admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") - parts.append( - f'' - f'' - ) + # Container nav fragments (markets, etc.) + container_nav = ctx.get("container_nav_html", "") + if container_nav: + parts.append(container_nav) + return "".join(parts) @@ -114,21 +111,6 @@ def _post_nav_html(ctx: dict) -> str: # Post admin header # --------------------------------------------------------------------------- -def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: - """Build the post-admin-level header row.""" - post = ctx.get("post") or {} - slug = post.get("slug", "") - link_href = call_url(ctx, "blog_url", f"/{slug}/admin/") - - return sexp( - '(~menu-row :id "post-admin-row" :level 2' - ' :link-href lh :link-label "admin" :icon "fa fa-cog"' - ' :child-id "post-admin-header-child" :oob oob)', - lh=link_href, - oob=oob, - ) - - # --------------------------------------------------------------------------- # Calendars header # --------------------------------------------------------------------------- @@ -347,11 +329,30 @@ def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str: # --------------------------------------------------------------------------- def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str: - """Build calendar admin header row.""" + """Build calendar admin header row with nav links.""" + from quart import url_for + calendar = ctx.get("calendar") + cal_slug = getattr(calendar, "slug", "") if calendar else "" + select_colours = ctx.get("select_colours", "") + + nav_parts = [] + if cal_slug: + for endpoint, label in [ + ("calendars.calendar.slots.get", "slots"), + ("calendars.calendar.admin.calendar_description_edit", "description"), + ]: + href = url_for(endpoint, calendar_slug=cal_slug) + nav_parts.append(sexp( + '(~nav-link :href h :label l :select-colours sc)', + h=href, l=label, sc=select_colours, + )) + + nav_html = "".join(nav_parts) return sexp( '(~menu-row :id "calendar-admin-row" :level 4' ' :link-label "admin" :icon "fa fa-cog"' - ' :child-id "calendar-admin-header-child" :oob oob)', + ' :nav-html nh :child-id "calendar-admin-header-child" :oob oob)', + nh=nav_html, oob=oob, ) @@ -1659,7 +1660,7 @@ async def render_calendars_page(ctx: dict) -> str: """Full page: calendars listing.""" content = _calendars_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _calendars_header_html(ctx) + child = _post_header_html(ctx) + _calendars_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1667,8 +1668,8 @@ async def render_calendars_page(ctx: dict) -> str: async def render_calendars_oob(ctx: dict) -> str: """OOB response: calendars listing.""" content = _calendars_main_panel_html(ctx) - oobs = _post_admin_header_html(ctx, oob=True) - oobs += _oob_header_html("post-admin-header-child", "calendars-header-child", + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "calendars-header-child", _calendars_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1681,7 +1682,7 @@ async def render_calendar_page(ctx: dict) -> str: """Full page: calendar month view.""" content = _calendar_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _calendar_header_html(ctx) + child = _post_header_html(ctx) + _calendar_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1703,7 +1704,7 @@ async def render_day_page(ctx: dict) -> str: """Full page: day detail.""" content = _day_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1726,7 +1727,7 @@ async def render_day_admin_page(ctx: dict) -> str: """Full page: day admin.""" content = _day_admin_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx) + _day_admin_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) @@ -1750,7 +1751,7 @@ async def render_calendar_admin_page(ctx: dict) -> str: """Full page: calendar admin.""" content = _calendar_admin_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1765,6 +1766,32 @@ async def render_calendar_admin_oob(ctx: dict) -> str: return oob_page(ctx, oobs_html=oobs, content_html=content) +# --------------------------------------------------------------------------- +# Slots +# --------------------------------------------------------------------------- + +async def render_slots_page(ctx: dict) -> str: + """Full page: slots listing.""" + from quart import g + slots = ctx.get("slots") or [] + calendar = ctx.get("calendar") + content = render_slots_table(slots, calendar) + hdr = root_header_html(ctx) + child = (_post_header_html(ctx) + + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)) + hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_slots_oob(ctx: dict) -> str: + """OOB response: slots listing.""" + slots = ctx.get("slots") or [] + calendar = ctx.get("calendar") + content = render_slots_table(slots, calendar) + oobs = _calendar_admin_header_html(ctx, oob=True) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + # --------------------------------------------------------------------------- # Tickets # --------------------------------------------------------------------------- @@ -1820,7 +1847,7 @@ async def render_markets_page(ctx: dict) -> str: """Full page: markets listing.""" content = _markets_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _markets_header_html(ctx) + child = _post_header_html(ctx) + _markets_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1828,8 +1855,8 @@ async def render_markets_page(ctx: dict) -> str: async def render_markets_oob(ctx: dict) -> str: """OOB response: markets listing.""" content = _markets_main_panel_html(ctx) - oobs = _post_admin_header_html(ctx, oob=True) - oobs += _oob_header_html("post-admin-header-child", "markets-header-child", + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "markets-header-child", _markets_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1842,7 +1869,7 @@ async def render_payments_page(ctx: dict) -> str: """Full page: payments admin.""" content = _payments_main_panel_html(ctx) hdr = root_header_html(ctx) - child = _post_header_html(ctx) + _post_admin_header_html(ctx) + _payments_header_html(ctx) + child = _post_header_html(ctx) + _payments_header_html(ctx) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1850,8 +1877,8 @@ async def render_payments_page(ctx: dict) -> str: async def render_payments_oob(ctx: dict) -> str: """OOB response: payments admin.""" content = _payments_main_panel_html(ctx) - oobs = _post_admin_header_html(ctx, oob=True) - oobs += _oob_header_html("post-admin-header-child", "payments-header-child", + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "payments-header-child", _payments_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -2322,7 +2349,7 @@ async def render_entry_page(ctx: dict) -> str: """Full page: entry detail.""" content = _entry_main_panel_html(ctx) hdr = root_header_html(ctx) - child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx) + _entry_header_html(ctx)) hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) diff --git a/orders/sexp/sexp_components.py b/orders/sexp/sexp_components.py index fc66526..058266f 100644 --- a/orders/sexp/sexp_components.py +++ b/orders/sexp/sexp_components.py @@ -14,7 +14,6 @@ from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) -from shared.sexp.page import HAMBURGER_HTML from shared.infrastructure.urls import market_product_url, cart_url diff --git a/relations/alembic/versions/0003_backfill_calendar_metadata.py b/relations/alembic/versions/0003_backfill_calendar_metadata.py new file mode 100644 index 0000000..c1af99f --- /dev/null +++ b/relations/alembic/versions/0003_backfill_calendar_metadata.py @@ -0,0 +1,30 @@ +"""Backfill label + metadata for calendar relations. + +Removes calendar relations with no metadata slug and no label, +which are either orphaned or were created before metadata was tracked. + +Revision ID: relations_0003 +Revises: relations_0002 +Create Date: 2026-02-28 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "relations_0003" +down_revision = "relations_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + DELETE FROM container_relations + WHERE child_type IN ('calendar', 'market') + AND (metadata IS NULL OR metadata->>'slug' IS NULL) + AND label IS NULL + """) + + +def downgrade() -> None: + pass diff --git a/relations/bp/fragments/routes.py b/relations/bp/fragments/routes.py index 2d2d9f8..d323720 100644 --- a/relations/bp/fragments/routes.py +++ b/relations/bp/fragments/routes.py @@ -43,6 +43,12 @@ def register(): from shared.sexp.jinja_bridge import sexp as render_sexp from shared.sexp.relations import relations_from from shared.services.relationships import get_children + from shared.infrastructure.urls import events_url, market_url + + _SERVICE_URL = { + "calendar": events_url, + "market": market_url, + } container_type = request.args.get("container_type", "page") container_id = int(request.args.get("container_id", 0)) @@ -70,7 +76,17 @@ def register(): ) for child in children: slug = (child.metadata_ or {}).get("slug", "") - href = f"/{post_slug}/{slug}/" if post_slug else f"/{slug}/" + if not slug: + continue + nav_label = defn.nav_label or "" + if post_slug and nav_label: + path = f"/{post_slug}/{nav_label}/{slug}/" + elif post_slug: + path = f"/{post_slug}/{slug}/" + else: + path = f"/{slug}/" + url_fn = _SERVICE_URL.get(defn.to_type) + href = url_fn(path) if url_fn else path parts.append(render_sexp( '(~relation-nav :href href :name name :icon icon :nav-class nav-class :relation-type relation-type)', href=href, diff --git a/shared/browser/app/redis_cacher.py b/shared/browser/app/redis_cacher.py index 154d410..571110d 100644 --- a/shared/browser/app/redis_cacher.py +++ b/shared/browser/app/redis_cacher.py @@ -187,7 +187,7 @@ def cache_page( async def wrapper(*args, **kwargs): r = get_redis() - if not r or request.method != "GET": + if not r or request.method != "GET" or current_app.config.get("NO_PAGE_CACHE"): return await view(*args, **kwargs) uid = get_user_id() diff --git a/shared/browser/templates/_types/root/_head.html b/shared/browser/templates/_types/root/_head.html index 26a487b..a28d178 100644 --- a/shared/browser/templates/_types/root/_head.html +++ b/shared/browser/templates/_types/root/_head.html @@ -6,6 +6,7 @@ + diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index e6ab90a..280122d 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -102,6 +102,10 @@ def create_base_app( # Cache app prefix for key namespacing app.config["CACHE_APP_PREFIX"] = name + # Disable page caching in development + env = os.getenv("ENVIRONMENT", "development") + app.config["NO_PAGE_CACHE"] = env not in ("production", "staging") + # --- infrastructure --- register_middleware(app) register_db(app) @@ -276,6 +280,15 @@ def create_base_app( response.delete_cookie("blog_session", domain=".rose-ash.com", path="/") return response + @app.after_request + async def _cors_for_subdomains(response): + origin = request.headers.get("Origin", "") + if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"): + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Headers"] = "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, HX-Boosted, Content-Type" + return response + @app.after_request async def _add_hx_preserve_search_header(response): value = request.headers.get("X-Search") diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index 66b9216..257a500 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Any from .jinja_bridge import sexp -from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP +from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP def call_url(ctx: dict, key: str, path: str = "/") -> str: @@ -34,14 +34,13 @@ def root_header_html(ctx: dict, *, oob: bool = False) -> str: return sexp( '(~header-row :cart-mini-html cmi :blog-url bu :site-title st' ' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph' - ' :hamburger-html hh :oob oob)', + ' :oob oob)', cmi=ctx.get("cart_mini_html", ""), bu=call_url(ctx, "blog_url", ""), st=ctx.get("base_title", ""), nth=ctx.get("nav_tree_html", ""), amh=ctx.get("auth_menu_html", ""), nph=ctx.get("nav_panel_html", ""), - hh=HAMBURGER_HTML, oob=oob, ) diff --git a/shared/sexp/page.py b/shared/sexp/page.py index fafddfa..a0b0dee 100644 --- a/shared/sexp/page.py +++ b/shared/sexp/page.py @@ -30,19 +30,6 @@ from typing import Any from .jinja_bridge import sexp -# HTML constants used by layout components — kept here to avoid -# s-expression parser issues with embedded quotes in SVG. -HAMBURGER_HTML = ( - '
' - '' - '' - '
' -) - SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}' SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}' @@ -88,6 +75,11 @@ async def get_template_context(**kwargs: Any) -> dict[str, Any]: if key not in ctx: ctx[key] = val + # Expose request-scoped values that sexp components need + from quart import g + if "rights" not in ctx: + ctx["rights"] = getattr(g, "rights", {}) + ctx.update(kwargs) return ctx diff --git a/shared/sexp/primitives.py b/shared/sexp/primitives.py index 4aac66d..cd0d504 100644 --- a/shared/sexp/primitives.py +++ b/shared/sexp/primitives.py @@ -255,7 +255,9 @@ def prim_join(sep: str, coll: list) -> str: return sep.join(str(x) for x in coll) @register_primitive("starts-with?") -def prim_starts_with(s: str, prefix: str) -> bool: +def prim_starts_with(s, prefix: str) -> bool: + if not isinstance(s, str): + return False return s.startswith(prefix) @register_primitive("ends-with?") diff --git a/shared/sexp/templates/layout.sexp b/shared/sexp/templates/layout.sexp index 9d96a9c..fa3273a 100644 --- a/shared/sexp/templates/layout.sexp +++ b/shared/sexp/templates/layout.sexp @@ -1,89 +1,76 @@ -(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-head (&key title asset-url meta-html) + (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") + (meta :name "htmx-config" :content "{\"selfRequestsOnly\":false}") + (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}"))) (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 "") - "
" - "
" - "
" - "
" - "
" - "
")))) + (<> + (raw! "") + (html :lang "en" + (~app-head :title (or title "Rose Ash") :asset-url asset-url :meta-html meta-html) + (body :class "bg-stone-50 text-stone-900" + (div :class "max-w-screen-2xl mx-auto py-1 px-1" + (div :class "w-full" + (details :class "group/root p-2" :data-toggle-group "mobile-panels" + (summary + (header :class "z-50" + (div :id "root-header-summary" + :class (str "flex items-start gap-2 p-1 bg-" colour "-500") + (div :class "flex flex-col w-full items-center" + (when header-rows-html (raw! header-rows-html)))))) + (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden" + (when menu-html (raw! menu-html))))) + (div :id "filter" + (when filter-html (raw! filter-html))) + (main :id "root-panel" :class "max-w-full" + (div :class "md:min-h-0" + (div :class "flex flex-row md:h-full md:min-h-0" + (aside :id "aside" + :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))) + (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)) + (div :class "pb-8")))))) + (when body-end-html (raw! body-end-html)) + (script :src (str asset-url "/scripts/body.js"))))))) (defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html) (<> @@ -99,9 +86,19 @@ :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 ~hamburger () + (div :class "md:hidden bg-stone-200 rounded" + (svg :class "h-12 w-12 transition-transform group-open/root:hidden block self-start" + :viewBox "0 0 24 24" :fill "none" :stroke "currentColor" + (path :stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" + :d "M4 6h16M4 12h16M4 18h16")) + (svg :aria-hidden "true" :viewBox "0 0 24 24" + :class "w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start" + (path :d "M6 9l6 6 6-6" :fill "currentColor")))) + (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) + settings-url is-admin oob) (<> (div :id "root-row" :hx-swap-oob (if oob "outerHTML" nil) @@ -118,7 +115,7 @@ (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)))) + (~hamburger))) (div :class "block md:hidden text-md font-bold" (when auth-menu-html (raw! auth-menu-html))))) @@ -145,11 +142,11 @@ (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 + (when (and child-id (not oob)) (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) +(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected) (div :class "relative nav-group" (a :href href :hx-get href @@ -157,6 +154,7 @@ :hx-select (or hx-select "#main-panel") :hx-swap "outerHTML" :hx-push-url "true" + :aria-selected (when is-selected "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 "")))