""" Blog service s-expression page components. Renders home, blog index (posts/pages), new post/page, post detail, post admin, post data, post entries, post edit, post settings, settings home, cache, snippets, menu items, and tag groups pages. Called from route handlers in place of ``render_template()``. """ from __future__ import annotations from typing import Any from markupsafe import escape from shared.sexp.jinja_bridge import sexp from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) # --------------------------------------------------------------------------- # OOB header helper # --------------------------------------------------------------------------- def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: """Wrap a header row in OOB div with child placeholder.""" return sexp( '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"' ' (div :class "w-full" (raw! rh)' ' (div :id cid)))', pid=parent_id, rh=row_html, cid=child_id, ) # --------------------------------------------------------------------------- # Blog header (root-header-child -> blog-header-child) # --------------------------------------------------------------------------- def _blog_header_html(ctx: dict, *, oob: bool = False) -> str: """Blog header row — empty child of root.""" return sexp( '(~menu-row :id "blog-row" :level 1' ' :link-label-html llh' ' :child-id "blog-header-child" :oob oob)', llh=sexp('(div)'), oob=oob, ) # --------------------------------------------------------------------------- # Post header helpers # --------------------------------------------------------------------------- def _post_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the post-level header row.""" post = ctx.get("post") or {} slug = post.get("slug", "") title = (post.get("title") or "")[:160] feature_image = post.get("feature_image") label_html = sexp( '(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))' ' (span t))', fi=feature_image, t=title, ) nav_parts = [] page_cart_count = ctx.get("page_cart_count", 0) if page_cart_count and page_cart_count > 0: cart_href = call_url(ctx, "cart_url", f"/{slug}/") nav_parts.append(sexp( '(a :href h :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full' ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"' ' (i :class "fa fa-shopping-cart" :aria-hidden "true")' ' (span c))', h=cart_href, c=str(page_cart_count), )) # Container nav fragments (calendars, markets) container_nav = ctx.get("container_nav_html", "") if container_nav: nav_parts.append(sexp( '(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" (raw! cn))', cn=container_nav, )) # Admin link 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: 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 nav_parts.append(sexp( '(~nav-link :href h :hx-select "#main-panel" :icon "fa fa-cog"' ' :aclass ac :select-colours sc :is-selected sel)', h=admin_href, ac=f"{nav_btn} {select_colours}", sc=select_colours, sel=is_admin_page, )) nav_html = "".join(nav_parts) link_href = call_url(ctx, "blog_url", f"/{slug}/") return sexp( '(~menu-row :id "post-row" :level 1' ' :link-href lh :link-label-html llh' ' :nav-html nh :child-id "post-header-child" :oob oob)', lh=link_href, llh=label_html, nh=nav_html, oob=oob, ) # --------------------------------------------------------------------------- # Post admin header # --------------------------------------------------------------------------- def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: """Post admin header row with admin icon and nav links.""" from quart import url_for as qurl post = ctx.get("post") or {} slug = post.get("slug", "") 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) label_html = sexp( '(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")', ) nav_html = _post_admin_nav_html(ctx) return sexp( '(~menu-row :id "post-admin-row" :level 2' ' :link-href lh :link-label-html llh' ' :nav-html nh :child-id "post-admin-header-child" :oob oob)', lh=admin_href, llh=label_html, nh=nav_html, oob=oob, ) def _post_admin_nav_html(ctx: dict) -> str: """Post admin desktop nav: calendars, markets, payments, entries, data, edit, settings.""" from quart import url_for as qurl post = ctx.get("post") or {} slug = post.get("slug", "") 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", "") parts = [] # External links to events service events_url_fn = ctx.get("events_url") if callable(events_url_fn): for path, label in [ (f"/{slug}/calendars/", "calendars"), (f"/{slug}/markets/", "markets"), (f"/{slug}/payments/", "payments"), ]: href = events_url_fn(path) parts.append(sexp( '(div :class "relative nav-group" (a :href h :class c l))', h=href, c=nav_btn, l=label, )) # HTMX links for endpoint, label in [ ("blog.post.admin.entries", "entries"), ("blog.post.admin.data", "data"), ("blog.post.admin.edit", "edit"), ("blog.post.admin.settings", "settings"), ]: href = qurl(endpoint, slug=slug) parts.append(sexp( '(~nav-link :href h :label l :select-colours sc)', h=href, l=label, sc=select_colours, )) return "".join(parts) # --------------------------------------------------------------------------- # Settings header (root-header-child -> root-settings-header-child) # --------------------------------------------------------------------------- def _settings_header_html(ctx: dict, *, oob: bool = False) -> str: """Settings header row with admin icon and nav links.""" from quart import url_for as qurl hx_select = ctx.get("hx_select_search", "#main-panel") settings_href = qurl("settings.home") label_html = sexp( '(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")', ) nav_html = _settings_nav_html(ctx) return sexp( '(~menu-row :id "root-settings-row" :level 1' ' :link-href lh :link-label-html llh' ' :nav-html nh :child-id "root-settings-header-child" :oob oob)', lh=settings_href, llh=label_html, nh=nav_html, oob=oob, ) def _settings_nav_html(ctx: dict) -> str: """Settings desktop nav: menu items, snippets, tag groups, cache.""" from quart import url_for as qurl select_colours = ctx.get("select_colours", "") parts = [] for endpoint, icon, label in [ ("menu_items.list_menu_items", "bars", "Menu Items"), ("snippets.list_snippets", "puzzle-piece", "Snippets"), ("blog.tag_groups_admin.index", "tags", "Tag Groups"), ("settings.cache", "refresh", "Cache"), ]: href = qurl(endpoint) parts.append(sexp( '(~nav-link :href h :icon ic :label l :select-colours sc)', h=href, ic=f"fa fa-{icon}", l=label, sc=select_colours, )) return "".join(parts) # --------------------------------------------------------------------------- # Sub-settings headers (root-settings-header-child -> X-header-child) # --------------------------------------------------------------------------- def _sub_settings_header_html(row_id: str, child_id: str, href: str, icon: str, label: str, ctx: dict, *, oob: bool = False, nav_html: str = "") -> str: """Generic sub-settings header row (menu_items, snippets, tag_groups, cache).""" select_colours = ctx.get("select_colours", "") label_html = sexp( '(<> (i :class ic :aria-hidden "true") " " l)', ic=f"fa fa-{icon}", l=label, ) return sexp( '(~menu-row :id rid :level 2' ' :link-href lh :link-label-html llh' ' :nav-html nh :child-id cid :oob oob)', rid=row_id, lh=href, llh=label_html, nh=nav_html, cid=child_id, oob=oob, ) def _post_sub_admin_header_html(row_id: str, child_id: str, href: str, icon: str, label: str, ctx: dict, *, oob: bool = False, nav_html: str = "") -> str: """Generic post sub-admin header row (data, edit, entries, settings).""" label_html = sexp( '(<> (i :class ic :aria-hidden "true") (div l))', ic=f"fa fa-{icon}", l=label, ) return sexp( '(~menu-row :id rid :level 3' ' :link-href lh :link-label-html llh' ' :nav-html nh :child-id cid :oob oob)', rid=row_id, lh=href, llh=label_html, nh=nav_html, cid=child_id, oob=oob, ) # --------------------------------------------------------------------------- # Blog index main panel helpers # --------------------------------------------------------------------------- def _blog_cards_html(ctx: dict) -> str: """Render blog post cards (list or tile).""" posts = ctx.get("posts") or [] view = ctx.get("view") parts = [] for p in posts: if view == "tile": parts.append(_blog_card_tile_html(p, ctx)) else: parts.append(_blog_card_html(p, ctx)) parts.append(_blog_sentinel_html(ctx)) return "".join(parts) def _blog_card_html(post: dict, ctx: dict) -> str: """Single blog post card (list view).""" from quart import url_for as qurl, g slug = post.get("slug", "") href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") user = getattr(g, "user", None) like_html = "" if user: liked = post.get("is_liked", False) like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") like_html = sexp( '(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"' ' (button :hx-post lu :hx-swap "outerHTML"' ' :hx-headers hh :class "cursor-pointer" heart))', lu=like_url, hh=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}', heart="\u2764\ufe0f" if liked else "\U0001f90d", ) status = post.get("status", "published") status_html = "" if status == "draft": pub_req = post.get("publish_requested") updated = post.get("updated_at") ts = "" if updated: ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) status_html = sexp( '(<> (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 pr (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))' ' (when ts (p :class "text-sm text-stone-500" (str "Updated: " ts))))', pr=pub_req, ts=ts, ) else: pub = post.get("published_at") if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) status_html = sexp( '(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts, ) fi = post.get("feature_image") excerpt = post.get("custom_excerpt") or post.get("excerpt", "") card_widgets = ctx.get("card_widgets_html") or {} widget = card_widgets.get(str(post.get("id", "")), "") at_bar = _at_bar_html(post, ctx) return sexp( '(article :class "border-b pb-6 last:border-b-0 relative"' ' (raw! like_html)' ' (a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :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" t)' ' (raw! sh))' ' (when fi (div :class "mb-4" (img :src fi :alt "" :class "rounded-lg w-full object-cover")))' ' (when ex (p :class "text-stone-700 text-lg leading-relaxed text-center" ex)))' ' (when wid (raw! wid))' ' (raw! ab))', like_html=like_html, h=href, hs=hx_select, t=post.get("title", ""), sh=status_html, fi=fi, ex=excerpt, wid=widget, ab=at_bar, ) def _blog_card_tile_html(post: dict, ctx: dict) -> str: """Single blog post card (tile view).""" slug = post.get("slug", "") href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") fi = post.get("feature_image") status = post.get("status", "published") status_html = "" if status == "draft": updated = post.get("updated_at") ts = "" if updated: ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) status_html = sexp( '(<> (div :class "flex justify-center gap-1 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 pr (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))' ' (when ts (p :class "text-sm text-stone-500" (str "Updated: " ts))))', pr=post.get("publish_requested"), ts=ts, ) else: pub = post.get("published_at") if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) status_html = sexp('(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts) excerpt = post.get("custom_excerpt") or post.get("excerpt", "") at_bar = _at_bar_html(post, ctx) return sexp( '(article :class "relative"' ' (a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' ' (when fi (div (img :src fi :alt "" :class "w-full aspect-video object-cover")))' ' (div :class "p-3 text-center"' ' (h2 :class "text-lg font-bold text-stone-900" t)' ' (raw! sh)' ' (when ex (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" ex))))' ' (raw! ab))', h=href, hs=hx_select, fi=fi, t=post.get("title", ""), sh=status_html, ex=excerpt, ab=at_bar, ) def _at_bar_html(post: dict, ctx: dict) -> str: """Tags + authors bar below a card.""" tags = post.get("tags") or [] authors = post.get("authors") or [] if not tags and not authors: return "" tag_items = "" if tags: tag_li = [] for t in tags: t_name = t.get("name") or getattr(t, "name", "") t_fi = t.get("feature_image") or getattr(t, "feature_image", None) if t_fi: icon = sexp( '(img :src fi :alt n :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")', fi=t_fi, n=t_name, ) else: init = (t_name[:1]) if t_name else "" icon = sexp( '(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center' ' border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" i)', i=init, ) tag_li.append(sexp( '(li (a :class "flex items-center gap-1" (raw! ic)' ' (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium' ' border border-stone-200" n)))', ic=icon, n=t_name, )) tag_items = sexp( '(div :class "mt-4 flex items-center gap-2" (div "in")' ' (ul :class "flex flex-wrap gap-2 text-sm" (raw! items)))', items="".join(tag_li), ) author_items = "" if authors: author_li = [] for a in authors: a_name = a.get("name") or getattr(a, "name", "") a_img = a.get("profile_image") or getattr(a, "profile_image", None) if a_img: author_li.append(sexp( '(li :class "flex items-center gap-1"' ' (img :src ai :alt n :class "h-5 w-5 rounded-full object-cover")' ' (span :class "text-stone-700" n))', ai=a_img, n=a_name, )) else: author_li.append(sexp( '(li :class "text-stone-700" n)', n=a_name, )) author_items = sexp( '(div :class "mt-4 flex items-center gap-2" (div "by")' ' (ul :class "flex flex-wrap gap-2 text-sm" (raw! items)))', items="".join(author_li), ) return sexp( '(div :class "flex flex-row justify-center gap-3"' ' (raw! ti) (div) (raw! ai))', ti=tag_items, ai=author_items, ) def _blog_sentinel_html(ctx: dict) -> str: """Infinite scroll sentinels for blog post list.""" page = ctx.get("page", 1) total_pages = ctx.get("total_pages", 1) if isinstance(total_pages, str): total_pages = int(total_pages) if page >= total_pages: return sexp('(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")') current_local_href = ctx.get("current_local_href", "/index") next_url = f"{current_local_href}?page={page + 1}" mobile_hs = ( "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end" " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end" " on resize from window if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end" " on htmx:beforeRequest if window.matchMedia('(min-width: 768px)').matches then halt end" " add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me" " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end" " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me" " wait ms ms trigger sentinelmobile:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end" " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) desktop_hs = ( "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end" " on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me" " remove .opacity-100 from me add .opacity-0 to me" " set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end" " if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport" " if scroller is null then halt end if scroller.scrollTop < 20 then halt end end" " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end" " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me" " wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end" " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) mobile = sexp( '(div :id mid :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"' ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"' ' :hx-swap "outerHTML" :_ mhs' ' :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")))', mid=f"sentinel-{page}-m", nu=next_url, mhs=mobile_hs, ) desktop = sexp( '(div :id did :class "hidden md:block h-4 opacity-0 pointer-events-none"' ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinel:retry"' ' :hx-swap "outerHTML" :_ dhs' ' :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"))', did=f"sentinel-{page}-d", nu=next_url, dhs=desktop_hs, ) return mobile + desktop def _page_cards_html(ctx: dict) -> str: """Render page cards with sentinel.""" pages = ctx.get("pages") or ctx.get("posts") or [] page_num = ctx.get("page", 1) total_pages = ctx.get("total_pages", 1) if isinstance(total_pages, str): total_pages = int(total_pages) hx_select = ctx.get("hx_select_search", "#main-panel") parts = [] for pg in pages: parts.append(_page_card_html(pg, ctx)) if page_num < total_pages: current_local_href = ctx.get("current_local_href", "/index?type=pages") next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}" parts.append(sexp( '(div :id sid :class "h-4 opacity-0 pointer-events-none"' ' :hx-get nu :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML")', sid=f"sentinel-{page_num}-d", nu=next_url, )) elif pages: parts.append(sexp('(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")')) else: parts.append(sexp('(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found.")')) return "".join(parts) def _page_card_html(page: dict, ctx: dict) -> str: """Single page card.""" slug = page.get("slug", "") href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") features = page.get("features") or {} badges_html = "" if features: badges_html = sexp( '(div :class "flex justify-center gap-2 mt-2"' ' (when cal (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"' ' (i :class "fa fa-calendar mr-1") "Calendar"))' ' (when mkt (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"' ' (i :class "fa fa-shopping-bag mr-1") "Market")))', cal=features.get("calendar"), mkt=features.get("market"), ) pub = page.get("published_at") pub_html = "" if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) pub_html = sexp('(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts) fi = page.get("feature_image") excerpt = page.get("custom_excerpt") or page.get("excerpt", "") return sexp( '(article :class "border-b pb-6 last:border-b-0 relative"' ' (a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :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" t)' ' (raw! bh) (raw! ph))' ' (when fi (div :class "mb-4" (img :src fi :alt "" :class "rounded-lg w-full object-cover")))' ' (when ex (p :class "text-stone-700 text-lg leading-relaxed text-center" ex))))', h=href, hs=hx_select, t=page.get("title", ""), bh=badges_html, ph=pub_html, fi=fi, ex=excerpt, ) def _view_toggle_html(ctx: dict) -> str: """View toggle bar (list/tile) for desktop.""" view = ctx.get("view") current_local_href = ctx.get("current_local_href", "/index") hx_select = ctx.get("hx_select_search", "#main-panel") list_cls = "bg-stone-200 text-stone-800" if view != "tile" else "text-stone-400 hover:text-stone-600" tile_cls = "bg-stone-200 text-stone-800" if view == "tile" else "text-stone-400 hover:text-stone-600" list_href = f"{current_local_href}" tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile" list_svg = sexp( '(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"))', ) tile_svg = sexp( '(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"))', ) return sexp( '(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"' ' (a :href lh :hx-get lh :hx-target "#main-panel" :hx-select hs' ' :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " lc) :title "List view"' ' :_ "on click js localStorage.removeItem(\'blog_view\') end" (raw! ls))' ' (a :href th :hx-get th :hx-target "#main-panel" :hx-select hs' ' :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tc) :title "Tile view"' ' :_ "on click js localStorage.setItem(\'blog_view\',\'tile\') end" (raw! ts)))', lh=list_href, th=tile_href, hs=hx_select, lc=list_cls, tc=tile_cls, ls=list_svg, ts=tile_svg, ) def _content_type_tabs_html(ctx: dict) -> str: """Posts/Pages tabs.""" from quart import url_for as qurl content_type = ctx.get("content_type", "posts") hx_select = ctx.get("hx_select_search", "#main-panel") posts_href = call_url(ctx, "blog_url", "/index") pages_href = f"{posts_href}?type=pages" posts_cls = "bg-stone-700 text-white" if content_type != "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200" pages_cls = "bg-stone-700 text-white" if content_type == "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200" return sexp( '(div :class "flex justify-center gap-1 px-3 pt-3"' ' (a :href ph :hx-get ph :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pc) "Posts")' ' (a :href pgh :hx-get pgh :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pgc) "Pages"))', ph=posts_href, pgh=pages_href, hs=hx_select, pc=posts_cls, pgc=pages_cls, ) def _blog_main_panel_html(ctx: dict) -> str: """Blog index main panel with tabs, toggle, and cards.""" content_type = ctx.get("content_type", "posts") view = ctx.get("view") tabs = _content_type_tabs_html(ctx) if content_type == "pages": cards = _page_cards_html(ctx) return sexp( '(<> (raw! tabs) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards)) (div :class "pb-8"))', tabs=tabs, cards=cards, ) else: toggle = _view_toggle_html(ctx) grid_cls = "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" if view == "tile" else "max-w-full px-3 py-3 space-y-3" cards = _blog_cards_html(ctx) return sexp( '(<> (raw! tabs) (raw! toggle) (div :class gc (raw! cards)) (div :class "pb-8"))', tabs=tabs, toggle=toggle, gc=grid_cls, cards=cards, ) # --------------------------------------------------------------------------- # Desktop aside (filter sidebar) # --------------------------------------------------------------------------- def _blog_aside_html(ctx: dict) -> str: """Desktop aside with search, action buttons, and filters.""" sd = search_desktop_html(ctx) ab = _action_buttons_html(ctx) tgf = _tag_groups_filter_html(ctx) af = _authors_filter_html(ctx) return sexp( '(<> (raw! sd) (raw! ab)' ' (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"' ' (raw! tgf) (raw! af))' ' (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML"))', sd=sd, ab=ab, tgf=tgf, af=af, ) def _blog_filter_html(ctx: dict) -> str: """Mobile filter (details/summary).""" current_local_href = ctx.get("current_local_href", "/index") search = ctx.get("search", "") search_count = ctx.get("search_count", "") hx_select = ctx.get("hx_select", "#main-panel") # Mobile filter summary tags summary_parts = [] summary_parts.append(_tag_groups_filter_summary_html(ctx)) summary_parts.append(_authors_filter_summary_html(ctx)) summary_html = "".join(summary_parts) filter_content = search_mobile_html(ctx) + summary_html action_buttons = _action_buttons_html(ctx) filter_details = _tag_groups_filter_html(ctx) + _authors_filter_html(ctx) return sexp( '(~mobile-filter :filter-summary-html fsh :action-buttons-html abh' ' :filter-details-html fdh)', fsh=filter_content, abh=action_buttons, fdh=filter_details, ) def _action_buttons_html(ctx: dict) -> str: """New Post/Page + Drafts toggle buttons.""" from quart import g rights = ctx.get("rights") or {} has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) user = getattr(g, "user", None) hx_select = ctx.get("hx_select_search", "#main-panel") drafts = ctx.get("drafts") draft_count = ctx.get("draft_count", 0) current_local_href = ctx.get("current_local_href", "/index") parts = [] if has_admin: new_href = call_url(ctx, "blog_url", "/new/") parts.append(sexp( '(a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"' ' :title "New Post" (i :class "fa fa-plus mr-1") " New Post")', h=new_href, hs=hx_select, )) new_page_href = call_url(ctx, "blog_url", "/new-page/") parts.append(sexp( '(a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"' ' :title "New Page" (i :class "fa fa-plus mr-1") " New Page")', h=new_page_href, hs=hx_select, )) if user and (draft_count or drafts): if drafts: off_href = f"{current_local_href}" parts.append(sexp( '(a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"' ' :title "Hide Drafts" (i :class "fa fa-file-text-o mr-1") " Drafts "' ' (span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" dc))', h=off_href, hs=hx_select, dc=str(draft_count), )) else: on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1" parts.append(sexp( '(a :href h :hx-get h :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"' ' :title "Show Drafts" (i :class "fa fa-file-text-o mr-1") " Drafts "' ' (span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" dc))', h=on_href, hs=hx_select, dc=str(draft_count), )) inner = "".join(parts) return sexp('(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner))', inner=inner) def _tag_groups_filter_html(ctx: dict) -> str: """Tag group filter bar for desktop/mobile.""" tag_groups = ctx.get("tag_groups") or [] selected_groups = ctx.get("selected_groups") or () selected_tags = ctx.get("selected_tags") or () hx_select = ctx.get("hx_select_search", "#main-panel") is_any = len(selected_groups) == 0 and len(selected_tags) == 0 any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" li_parts = [sexp( '(li (a :class (str "px-3 py-1 rounded border " ac)' ' :hx-get "?page=1" :hx-target "#main-panel" :hx-select hs' ' :hx-swap "outerHTML" :hx-push-url "true" "Any Topic"))', ac=any_cls, hs=hx_select, )] for group in tag_groups: g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "") g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image") g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour") g_count = getattr(group, "post_count", 0) if hasattr(group, "post_count") else group.get("post_count", 0) if g_count <= 0 and g_slug not in selected_groups: continue is_on = g_slug in selected_groups cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" if g_fi: icon = sexp( '(img :src fi :alt n :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0")', fi=g_fi, n=g_name, ) else: style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" icon = sexp( '(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center' ' border border-stone-300 flex-shrink-0" :style st i)', st=style, i=g_name[:1], ) li_parts.append(sexp( '(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " c)' ' :hx-get hg :hx-target "#main-panel" :hx-select hs' ' :hx-swap "outerHTML" :hx-push-url "true"' ' (raw! ic)' ' (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" n)' ' (span :class "flex-1")' ' (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" gc)))', c=cls, hg=f"?group={g_slug}&page=1", hs=hx_select, ic=icon, n=g_name, gc=str(g_count), )) items = "".join(li_parts) return sexp( '(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"' ' (ul :class "divide-y flex flex-col gap-3" (raw! items)))', items=items, ) def _authors_filter_html(ctx: dict) -> str: """Author filter bar for desktop/mobile.""" authors = ctx.get("authors") or [] selected_authors = ctx.get("selected_authors") or () hx_select = ctx.get("hx_select_search", "#main-panel") is_any = len(selected_authors) == 0 any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" li_parts = [sexp( '(li (a :class (str "px-3 py-1 rounded " ac)' ' :hx-get "?page=1" :hx-target "#main-panel" :hx-select hs' ' :hx-swap "outerHTML" :hx-push-url "true" "Any author"))', ac=any_cls, hs=hx_select, )] for author in authors: a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "") a_name = getattr(author, "name", "") if hasattr(author, "name") else author.get("name", "") a_img = getattr(author, "profile_image", None) if hasattr(author, "profile_image") else author.get("profile_image") a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0) is_on = a_slug in selected_authors cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" icon = "" if a_img: icon = sexp( '(img :src ai :alt n :class "h-5 w-5 rounded-full object-cover")', ai=a_img, n=a_name, ) li_parts.append(sexp( '(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " c)' ' :hx-get hg :hx-target "#main-panel" :hx-select hs' ' :hx-swap "outerHTML" :hx-push-url "true"' ' (raw! ic)' ' (span :class "text-stone-700" n)' ' (span :class "flex-1")' ' (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" ac)))', c=cls, hg=f"?author={a_slug}&page=1", hs=hx_select, ic=icon, n=a_name, ac=str(a_count), )) items = "".join(li_parts) return sexp( '(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"' ' (ul :class "divide-y flex flex-col gap-3" (raw! items)))', items=items, ) def _tag_groups_filter_summary_html(ctx: dict) -> str: """Mobile filter summary for tag groups.""" selected_groups = ctx.get("selected_groups") or () tag_groups = ctx.get("tag_groups") or [] if not selected_groups: return "" names = [] for g in tag_groups: g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "") g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "") if g_slug in selected_groups: names.append(g_name) if not names: return "" return sexp('(span :class "text-sm text-stone-600" t)', t=", ".join(names)) def _authors_filter_summary_html(ctx: dict) -> str: """Mobile filter summary for authors.""" selected_authors = ctx.get("selected_authors") or () authors = ctx.get("authors") or [] if not selected_authors: return "" names = [] for a in authors: a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "") a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "") if a_slug in selected_authors: names.append(a_name) if not names: return "" return sexp('(span :class "text-sm text-stone-600" t)', t=", ".join(names)) # --------------------------------------------------------------------------- # Post detail main panel # --------------------------------------------------------------------------- def _post_main_panel_html(ctx: dict) -> str: """Post/page article content.""" from quart import g, url_for as qurl post = ctx.get("post") or {} slug = post.get("slug", "") user = getattr(g, "user", None) rights = ctx.get("rights") or {} is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) hx_select = ctx.get("hx_select_search", "#main-panel") # Draft indicator draft_html = "" if post.get("status") == "draft": edit_html = "" if is_admin or (user and post.get("user_id") == getattr(user, "id", None)): edit_href = qurl("blog.post.admin.edit", slug=slug) edit_html = sexp( '(a :href eh :hx-get eh :hx-target "#main-panel"' ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' ' :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"' ' (i :class "fa fa-pencil mr-1") " Edit")', eh=edit_href, hs=hx_select, ) draft_html = sexp( '(div :class "flex items-center justify-center gap-2 mb-3"' ' (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")' ' (when pr (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))' ' (raw! eh))', pr=post.get("publish_requested"), eh=edit_html, ) # Blog post chrome (not for pages) chrome_html = "" if not post.get("is_page"): like_html = "" if user: liked = post.get("is_liked", False) like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") like_html = sexp( '(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"' ' (button :hx-post lu :hx-swap "outerHTML"' ' :hx-headers hh :class "cursor-pointer" heart))', lu=like_url, hh=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}', heart="\u2764\ufe0f" if liked else "\U0001f90d", ) excerpt_html = "" if post.get("custom_excerpt"): excerpt_html = sexp( '(div :class "w-full text-center italic text-3xl p-2" ex)', ex=post["custom_excerpt"], ) at_bar = _at_bar_html(post, ctx) chrome_html = sexp( '(<> (raw! lh) (raw! exh) (div :class "hidden md:block" (raw! ab)))', lh=like_html, exh=excerpt_html, ab=at_bar, ) fi = post.get("feature_image") html_content = post.get("html", "") return sexp( '(<> (article :class "relative"' ' (raw! dh) (raw! ch)' ' (when fi (div :class "mb-3 flex justify-center"' ' (img :src fi :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))' ' (when hc (div :class "blog-content p-2" (raw! hc))))' ' (div :class "pb-8"))', dh=draft_html, ch=chrome_html, fi=fi, hc=html_content, ) def _post_meta_html(ctx: dict) -> str: """Post SEO meta tags (Open Graph, Twitter, JSON-LD).""" post = ctx.get("post") or {} base_title = ctx.get("base_title", "") is_public = post.get("visibility") == "public" is_published = post.get("status") == "published" email_only = post.get("email_only", False) robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow" # Description desc = (post.get("meta_description") or post.get("og_description") or post.get("twitter_description") or post.get("custom_excerpt") or post.get("excerpt") or "") if not desc and post.get("html"): import re desc = re.sub(r'<[^>]+>', '', post["html"]) desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160] # Image image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "") # Canonical from quart import request as req canonical = post.get("canonical_url") or (req.url if req else "") og_title = post.get("og_title") or base_title tw_title = post.get("twitter_title") or base_title is_article = not post.get("is_page") return sexp( '(<>' ' (meta :name "robots" :content robots)' ' (title bt)' ' (meta :name "description" :content desc)' ' (when canon (link :rel "canonical" :href canon))' ' (meta :property "og:type" :content ogt)' ' (meta :property "og:title" :content og_title)' ' (meta :property "og:description" :content desc)' ' (when canon (meta :property "og:url" :content canon))' ' (when image (meta :property "og:image" :content image))' ' (meta :name "twitter:card" :content twc)' ' (meta :name "twitter:title" :content tw_title)' ' (meta :name "twitter:description" :content desc)' ' (when image (meta :name "twitter:image" :content image)))', robots=robots, bt=base_title, desc=desc, canon=canonical, ogt="article" if is_article else "website", og_title=og_title, image=image, twc="summary_large_image" if image else "summary", tw_title=tw_title, ) # --------------------------------------------------------------------------- # Home page (Ghost "home" page) # --------------------------------------------------------------------------- def _home_main_panel_html(ctx: dict) -> str: """Home page content — renders the Ghost page HTML.""" post = ctx.get("post") or {} html = post.get("html", "") return sexp('(article :class "relative" (div :class "blog-content p-2" (raw! h)))', h=html) # --------------------------------------------------------------------------- # Post admin - empty main panel # --------------------------------------------------------------------------- def _post_admin_main_panel_html(ctx: dict) -> str: return sexp('(div :class "pb-8")') # --------------------------------------------------------------------------- # Settings main panels # --------------------------------------------------------------------------- def _settings_main_panel_html(ctx: dict) -> str: return sexp('(div :class "max-w-2xl mx-auto px-4 py-6")') def _cache_main_panel_html(ctx: dict) -> str: from quart import url_for as qurl csrf = ctx.get("csrf_token", "") clear_url = qurl("settings.cache_clear") return sexp( '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"' ' (div :class "flex flex-col md:flex-row gap-3 items-start"' ' (form :hx-post cu :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))' ' (div :id "cache-status" :class "py-2")))', cu=clear_url, csrf=csrf, ) # --------------------------------------------------------------------------- # Snippets main panel # --------------------------------------------------------------------------- def _snippets_main_panel_html(ctx: dict) -> str: sl = _snippets_list_html(ctx) return sexp( '(div :class "max-w-4xl mx-auto p-6"' ' (div :class "mb-6 flex justify-between items-center"' ' (h1 :class "text-3xl font-bold" "Snippets"))' ' (div :id "snippets-list" (raw! sl)))', sl=sl, ) def _snippets_list_html(ctx: dict) -> str: """Snippets list with visibility badges and delete buttons.""" from quart import url_for as qurl, g snippets = ctx.get("snippets") or [] is_admin = ctx.get("is_admin", False) csrf = ctx.get("csrf_token", "") user = getattr(g, "user", None) user_id = getattr(user, "id", None) if not snippets: return sexp( '(div :class "bg-white rounded-lg shadow"' ' (div :class "p-8 text-center text-stone-400"' ' (i :class "fa fa-puzzle-piece text-4xl mb-2")' ' (p "No snippets yet. Create one from the blog editor.")))', ) badge_colours = { "private": "bg-stone-200 text-stone-700", "shared": "bg-blue-100 text-blue-700", "admin": "bg-amber-100 text-amber-700", } row_parts = [] for s in snippets: s_id = getattr(s, "id", None) or s.get("id") s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "") s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id") s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private") owner = "You" if s_uid == user_id else f"User #{s_uid}" badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700") extra = "" if is_admin: patch_url = qurl("snippets.patch_visibility", snippet_id=s_id) opts = "" for v in ["private", "shared", "admin"]: opts += sexp( '(option :value v :selected sel v)', v=v, sel=(s_vis == v), ) extra += sexp( '(select :name "visibility" :hx-patch pu :hx-target "#snippets-list" :hx-swap "innerHTML"' ' :hx-headers hh :class "text-sm border border-stone-300 rounded px-2 py-1"' ' (raw! opts))', pu=patch_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', opts=opts, ) if s_uid == user_id or is_admin: del_url = qurl("snippets.delete_snippet", snippet_id=s_id) extra += sexp( '(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"' ' :data-confirm-text ct :data-confirm-icon "warning"' ' :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"' ' :data-confirm-event "confirmed"' ' :hx-delete du :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"' ' :hx-headers hh' ' :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"' ' (i :class "fa fa-trash") " Delete")', ct=f'Delete \u201c{s_name}\u201d?', du=del_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', ) row_parts.append(sexp( '(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"' ' (div :class "flex-1 min-w-0"' ' (div :class "font-medium truncate" sn)' ' (div :class "text-xs text-stone-500" ow))' ' (span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " bc) sv)' ' (raw! ex))', sn=s_name, ow=owner, bc=badge_cls, sv=s_vis, ex=extra, )) rows = "".join(row_parts) return sexp( '(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows)))', rows=rows, ) # --------------------------------------------------------------------------- # Menu items main panel # --------------------------------------------------------------------------- def _menu_items_main_panel_html(ctx: dict) -> str: from quart import url_for as qurl new_url = qurl("menu_items.new_menu_item") ml = _menu_items_list_html(ctx) return sexp( '(div :class "max-w-4xl mx-auto p-6"' ' (div :class "mb-6 flex justify-end items-center"' ' (button :type "button" :hx-get nu :hx-target "#menu-item-form" :hx-swap "innerHTML"' ' :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"' ' (i :class "fa fa-plus") " Add Menu Item"))' ' (div :id "menu-item-form" :class "mb-6")' ' (div :id "menu-items-list" (raw! ml)))', nu=new_url, ml=ml, ) def _menu_items_list_html(ctx: dict) -> str: from quart import url_for as qurl menu_items = ctx.get("menu_items") or [] csrf = ctx.get("csrf_token", "") if not menu_items: return sexp( '(div :class "bg-white rounded-lg shadow"' ' (div :class "p-8 text-center text-stone-400"' ' (i :class "fa fa-inbox text-4xl mb-2")' ' (p "No menu items yet. Add one to get started!")))', ) row_parts = [] for item in menu_items: i_id = getattr(item, "id", None) or item.get("id") label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "") slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image") sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0) edit_url = qurl("menu_items.edit_menu_item", item_id=i_id) del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id) img_html = sexp( '(if fi (img :src fi :alt lb :class "w-12 h-12 rounded-full object-cover flex-shrink-0")' ' (div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"))', fi=fi, lb=label, ) row_parts.append(sexp( '(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"' ' (div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))' ' (raw! img)' ' (div :class "flex-1 min-w-0"' ' (div :class "font-medium truncate" lb)' ' (div :class "text-xs text-stone-500 truncate" sl))' ' (div :class "text-sm text-stone-500" (str "Order: " so))' ' (div :class "flex gap-2 flex-shrink-0"' ' (button :type "button" :hx-get eu :hx-target "#menu-item-form" :hx-swap "innerHTML"' ' :class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"' ' (i :class "fa fa-edit") " Edit")' ' (button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"' ' :data-confirm-text ct :data-confirm-icon "warning"' ' :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"' ' :data-confirm-event "confirmed"' ' :hx-delete du :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"' ' :hx-headers hh' ' :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"' ' (i :class "fa fa-trash") " Delete")))', img=img_html, lb=label, sl=slug, so=str(sort), eu=edit_url, du=del_url, ct=f"Remove {label} from the menu?", hh=f'{{"X-CSRFToken": "{csrf}"}}', )) rows = "".join(row_parts) return sexp( '(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows)))', rows=rows, ) # --------------------------------------------------------------------------- # Tag groups main panel # --------------------------------------------------------------------------- def _tag_groups_main_panel_html(ctx: dict) -> str: from quart import url_for as qurl groups = ctx.get("groups") or [] unassigned_tags = ctx.get("unassigned_tags") or [] csrf = ctx.get("csrf_token", "") create_url = qurl("blog.tag_groups_admin.create") form_html = sexp( '(form :method "post" :action cu :class "border rounded p-4 bg-white space-y-3"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (h3 :class "text-sm font-semibold text-stone-700" "New Group")' ' (div :class "flex flex-col sm:flex-row gap-3"' ' (input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")' ' (input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")' ' (input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))' ' (input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")' ' (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create"))', cu=create_url, csrf=csrf, ) # Groups list groups_html = "" if groups: li_parts = [] for group in groups: g_id = getattr(group, "id", None) or group.get("id") g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "") g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image") g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour") g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0) edit_href = qurl("blog.tag_groups_admin.edit", id=g_id) if g_fi: icon = sexp( '(img :src fi :alt n :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")', fi=g_fi, n=g_name, ) else: style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" icon = sexp( '(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"' ' :style st i)', st=style, i=g_name[:1], ) li_parts.append(sexp( '(li :class "border rounded p-3 bg-white flex items-center gap-3"' ' (raw! ic)' ' (div :class "flex-1"' ' (a :href eh :class "font-medium text-stone-800 hover:underline" gn)' ' (span :class "text-xs text-stone-500 ml-2" gs))' ' (span :class "text-xs text-stone-500" (str "order: " so)))', ic=icon, eh=edit_href, gn=g_name, gs=g_slug, so=str(g_sort), )) groups_html = sexp( '(ul :class "space-y-2" (raw! items))', items="".join(li_parts), ) else: groups_html = sexp('(p :class "text-stone-500 text-sm" "No tag groups yet.")') # Unassigned tags unassigned_html = "" if unassigned_tags: tag_spans = [] for tag in unassigned_tags: t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") tag_spans.append(sexp( '(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" tn)', tn=t_name, )) unassigned_html = sexp( '(div :class "border-t pt-4"' ' (h3 :class "text-sm font-semibold text-stone-700 mb-2" hd)' ' (div :class "flex flex-wrap gap-2" (raw! spans)))', hd=f"Unassigned Tags ({len(unassigned_tags)})", spans="".join(tag_spans), ) return sexp( '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"' ' (raw! fh) (raw! gh) (raw! uh))', fh=form_html, gh=groups_html, uh=unassigned_html, ) def _tag_groups_edit_main_panel_html(ctx: dict) -> str: from quart import url_for as qurl group = ctx.get("group") all_tags = ctx.get("all_tags") or [] assigned_tag_ids = ctx.get("assigned_tag_ids") or set() csrf = ctx.get("csrf_token", "") g_id = getattr(group, "id", None) or group.get("id") if group else None g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "") g_colour = getattr(group, "colour", "") if hasattr(group, "colour") else (group.get("colour", "") if group else "") g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else (group.get("sort_order", 0) if group else 0) g_fi = getattr(group, "feature_image", "") if hasattr(group, "feature_image") else (group.get("feature_image", "") if group else "") save_url = qurl("blog.tag_groups_admin.save", id=g_id) del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id) # Tag checkboxes tag_items = [] for tag in all_tags: t_id = getattr(tag, "id", None) or tag.get("id") t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image") checked = t_id in assigned_tag_ids img = sexp( '(img :src fi :alt "" :class "h-4 w-4 rounded-full object-cover")', fi=t_fi, ) if t_fi else "" tag_items.append(sexp( '(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"' ' (input :type "checkbox" :name "tag_ids" :value tid :checked ch :class "rounded border-stone-300")' ' (raw! im) (span tn))', tid=str(t_id), ch=checked, im=img, tn=t_name, )) edit_form = sexp( '(form :method "post" :action su :class "border rounded p-4 bg-white space-y-4"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (div :class "space-y-3"' ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")' ' (input :type "text" :name "name" :value gn :required "" :class "w-full border rounded px-3 py-2 text-sm"))' ' (div :class "flex gap-3"' ' (div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")' ' (input :type "text" :name "colour" :value gc :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))' ' (div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")' ' (input :type "number" :name "sort_order" :value gs :class "w-full border rounded px-3 py-2 text-sm")))' ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")' ' (input :type "text" :name "feature_image" :value gfi :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))' ' (div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")' ' (div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"' ' (raw! tags)))' ' (div :class "flex gap-3"' ' (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save")))', su=save_url, csrf=csrf, gn=g_name, gc=g_colour or "", gs=str(g_sort), gfi=g_fi or "", tags="".join(tag_items), ) del_form = sexp( '(form :method "post" :action du :class "border-t pt-4"' ' :onsubmit "return confirm(\'Delete this tag group? Tags will not be deleted.\')"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group"))', du=del_url, csrf=csrf, ) return sexp( '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"' ' (raw! ef) (raw! df))', ef=edit_form, df=del_form, ) # --------------------------------------------------------------------------- # New post/page main panel — left as render_template (uses Koenig editor JS) # Post edit main panel — left as render_template (uses Koenig editor JS) # Post settings main panel — left as render_template (complex form macros) # Post entries main panel — left as render_template (calendar browser lazy-loads) # Post data main panel — left as render_template (uses ORM introspection macros) # --------------------------------------------------------------------------- # =========================================================================== # PUBLIC API — called from route handlers # =========================================================================== # ---- Home page ---- async def render_home_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) header_rows = root_hdr + post_hdr content = _home_main_panel_html(ctx) meta = _post_meta_html(ctx) menu_html = ctx.get("nav_html", "") or "" return full_page(ctx, header_rows_html=header_rows, content_html=content, meta_html=meta, menu_html=menu_html) async def render_home_oob(ctx: dict) -> str: root_hdr = root_header_html(ctx, oob=True) post_oob = _oob_header_html("root-header-child", "post-header-child", _post_header_html(ctx)) content = _home_main_panel_html(ctx) return oob_page(ctx, oobs_html=root_hdr + post_oob, content_html=content) # ---- Blog index ---- async def render_blog_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) blog_hdr = _blog_header_html(ctx) header_rows = root_hdr + blog_hdr content = _blog_main_panel_html(ctx) aside = _blog_aside_html(ctx) filter_html = _blog_filter_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content, aside_html=aside, filter_html=filter_html) async def render_blog_oob(ctx: dict) -> str: root_hdr = root_header_html(ctx, oob=True) blog_oob = _oob_header_html("root-header-child", "blog-header-child", _blog_header_html(ctx)) content = _blog_main_panel_html(ctx) aside = _blog_aside_html(ctx) filter_html = _blog_filter_html(ctx) nav_html = ctx.get("nav_html", "") or "" return oob_page(ctx, oobs_html=root_hdr + blog_oob, content_html=content, aside_html=aside, filter_html=filter_html, menu_html=nav_html) async def render_blog_cards(ctx: dict) -> str: """Pagination-only response (page > 1).""" return _blog_cards_html(ctx) async def render_blog_page_cards(ctx: dict) -> str: """Page cards pagination response.""" return _page_cards_html(ctx) # ---- New post/page editor panel ---- def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: """Build the WYSIWYG editor panel HTML (replaces _main_panel.html template). This is synchronous — it just assembles an HTML string from the current request context (url_for, CSRF token, asset URLs, config). """ import os from quart import url_for as qurl, current_app from shared.browser.app.csrf import generate_csrf_token from markupsafe import escape as esc csrf = generate_csrf_token() asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") editor_css = asset_url_fn("scripts/editor.css") editor_js = asset_url_fn("scripts/editor.js") upload_image_url = qurl("blog.editor_api.upload_image") upload_media_url = qurl("blog.editor_api.upload_media") upload_file_url = qurl("blog.editor_api.upload_file") oembed_url = qurl("blog.editor_api.oembed_proxy") snippets_url = qurl("blog.editor_api.list_snippets") unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") title_placeholder = "Page title..." if is_page else "Post title..." create_label = "Create Page" if is_page else "Create Post" parts: list[str] = [] # Error banner if save_error: parts.append(sexp( '(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300' ' bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"' ' (strong "Save failed:") " " err)', err=str(save_error), )) # Form structure form_html = sexp( '(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")' ' (input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")' ' (input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")' ' (div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"' ' (div :id "feature-image-empty"' ' (button :type "button" :id "feature-image-add-btn"' ' :class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"' ' "+ Add feature image"))' ' (div :id "feature-image-filled" :class "relative hidden"' ' (img :id "feature-image-preview" :src "" :alt ""' ' :class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")' ' (button :type "button" :id "feature-image-delete-btn"' ' :class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white' ' flex items-center justify-center opacity-0 group-hover:opacity-100' ' transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"' ' :title "Remove feature image"' ' (i :class "fa-solid fa-trash-can"))' ' (input :type "text" :id "feature-image-caption" :value ""' ' :placeholder "Add a caption..."' ' :class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none' ' outline-none placeholder:text-stone-300 focus:text-stone-700"))' ' (div :id "feature-image-uploading"' ' :class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"' ' (i :class "fa-solid fa-spinner fa-spin") " Uploading...")' ' (input :type "file" :id "feature-image-file"' ' :accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))' ' (input :type "text" :name "title" :value "" :placeholder tp' ' :class "w-full text-[36px] font-bold bg-transparent border-none outline-none' ' placeholder:text-stone-300 mb-[8px] leading-tight")' ' (textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."' ' :class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none' ' placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed")' ' (div :id "lexical-editor" :class "relative w-full bg-transparent")' ' (div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"' ' (select :name "status"' ' :class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"' ' (option :value "draft" :selected t "Draft")' ' (option :value "published" "Published"))' ' (button :type "submit"' ' :class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]' ' hover:bg-stone-800 transition-colors cursor-pointer" cl)))', csrf=csrf, tp=title_placeholder, cl=create_label, t=True, ) parts.append(form_html) # Editor CSS + inline styles parts.append(sexp( '(<> (link :rel "stylesheet" :href ecss)' ' (style' ' "#lexical-editor { display: flow-root; }"' ' "#lexical-editor [data-kg-card=\\"html\\"] * { float: none !important; }"' ' "#lexical-editor [data-kg-card=\\"html\\"] table { width: 100% !important; }"))', ecss=editor_css, )) # Editor JS + init script — kept as raw HTML due to complex JS with quotes parts.append(sexp('(script :src ejs)', ejs=editor_js)) parts.append( "" ) return "".join(parts) # ---- New post/page ---- async def render_new_post_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) blog_hdr = _blog_header_html(ctx) header_rows = root_hdr + blog_hdr content = ctx.get("editor_html", "") return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_new_post_oob(ctx: dict) -> str: root_hdr = root_header_html(ctx, oob=True) blog_oob = _blog_header_html(ctx, oob=True) content = ctx.get("editor_html", "") return oob_page(ctx, oobs_html=root_hdr + blog_oob, content_html=content) # ---- Post detail ---- async def render_post_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) header_rows = root_hdr + post_hdr content = _post_main_panel_html(ctx) meta = _post_meta_html(ctx) menu_html = ctx.get("nav_html", "") or "" return full_page(ctx, header_rows_html=header_rows, content_html=content, meta_html=meta, menu_html=menu_html) async def render_post_oob(ctx: dict) -> str: root_hdr = root_header_html(ctx, oob=True) post_oob = _oob_header_html("root-header-child", "post-header-child", _post_header_html(ctx)) content = _post_main_panel_html(ctx) menu_html = ctx.get("nav_html", "") or "" return oob_page(ctx, oobs_html=root_hdr + post_oob, content_html=content, menu_html=menu_html) # ---- Post admin ---- async def render_post_admin_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) admin_hdr = _post_admin_header_html(ctx) header_rows = root_hdr + post_hdr + admin_hdr content = _post_admin_main_panel_html(ctx) menu_html = ctx.get("nav_html", "") or "" return full_page(ctx, header_rows_html=header_rows, content_html=content, menu_html=menu_html) async def render_post_admin_oob(ctx: dict) -> str: post_hdr_oob = _post_header_html(ctx, oob=True) admin_oob = _oob_header_html("post-header-child", "post-admin-header-child", _post_admin_header_html(ctx)) content = _post_admin_main_panel_html(ctx) menu_html = ctx.get("nav_html", "") or "" return oob_page(ctx, oobs_html=post_hdr_oob + admin_oob, content_html=content, menu_html=menu_html) # ---- Post data ---- async def render_post_data_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) admin_hdr = _post_admin_header_html(ctx) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") data_hdr = _post_sub_admin_header_html( "post_data-row", "post_data-header-child", qurl("blog.post.admin.data", slug=slug), "database", "data", ctx, ) header_rows = root_hdr + post_hdr + admin_hdr + data_hdr content = ctx.get("data_html", "") return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_post_data_oob(ctx: dict) -> str: admin_hdr_oob = _post_admin_header_html(ctx, oob=True) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") data_hdr = _post_sub_admin_header_html( "post_data-row", "post_data-header-child", qurl("blog.post.admin.data", slug=slug), "database", "data", ctx, ) data_oob = _oob_header_html("post-admin-header-child", "post_data-header-child", data_hdr) content = ctx.get("data_html", "") return oob_page(ctx, oobs_html=admin_hdr_oob + data_oob, content_html=content) # ---- Post entries ---- async def render_post_entries_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) admin_hdr = _post_admin_header_html(ctx) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") entries_hdr = _post_sub_admin_header_html( "post_entries-row", "post_entries-header-child", qurl("blog.post.admin.entries", slug=slug), "clock", "entries", ctx, ) header_rows = root_hdr + post_hdr + admin_hdr + entries_hdr content = ctx.get("entries_html", "") return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_post_entries_oob(ctx: dict) -> str: admin_hdr_oob = _post_admin_header_html(ctx, oob=True) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") entries_hdr = _post_sub_admin_header_html( "post_entries-row", "post_entries-header-child", qurl("blog.post.admin.entries", slug=slug), "clock", "entries", ctx, ) entries_oob = _oob_header_html("post-admin-header-child", "post_entries-header-child", entries_hdr) content = ctx.get("entries_html", "") return oob_page(ctx, oobs_html=admin_hdr_oob + entries_oob, content_html=content) # ---- Post edit ---- async def render_post_edit_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) admin_hdr = _post_admin_header_html(ctx) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") edit_hdr = _post_sub_admin_header_html( "post_edit-row", "post_edit-header-child", qurl("blog.post.admin.edit", slug=slug), "pen-to-square", "edit", ctx, ) header_rows = root_hdr + post_hdr + admin_hdr + edit_hdr content = ctx.get("edit_html", "") body_end = ctx.get("body_end_html", "") return full_page(ctx, header_rows_html=header_rows, content_html=content, body_end_html=body_end) async def render_post_edit_oob(ctx: dict) -> str: admin_hdr_oob = _post_admin_header_html(ctx, oob=True) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") edit_hdr = _post_sub_admin_header_html( "post_edit-row", "post_edit-header-child", qurl("blog.post.admin.edit", slug=slug), "pen-to-square", "edit", ctx, ) edit_oob = _oob_header_html("post-admin-header-child", "post_edit-header-child", edit_hdr) content = ctx.get("edit_html", "") return oob_page(ctx, oobs_html=admin_hdr_oob + edit_oob, content_html=content) # ---- Post settings ---- async def render_post_settings_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) post_hdr = _post_header_html(ctx) admin_hdr = _post_admin_header_html(ctx) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") settings_hdr = _post_sub_admin_header_html( "post_settings-row", "post_settings-header-child", qurl("blog.post.admin.settings", slug=slug), "cog", "settings", ctx, ) header_rows = root_hdr + post_hdr + admin_hdr + settings_hdr content = ctx.get("settings_html", "") return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_post_settings_oob(ctx: dict) -> str: admin_hdr_oob = _post_admin_header_html(ctx, oob=True) from quart import url_for as qurl slug = (ctx.get("post") or {}).get("slug", "") settings_hdr = _post_sub_admin_header_html( "post_settings-row", "post_settings-header-child", qurl("blog.post.admin.settings", slug=slug), "cog", "settings", ctx, ) settings_oob = _oob_header_html("post-admin-header-child", "post_settings-header-child", settings_hdr) content = ctx.get("settings_html", "") return oob_page(ctx, oobs_html=admin_hdr_oob + settings_oob, content_html=content) # ---- Settings home ---- async def render_settings_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) settings_hdr = _settings_header_html(ctx) header_rows = root_hdr + settings_hdr content = _settings_main_panel_html(ctx) menu_html = _settings_nav_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content, menu_html=menu_html) async def render_settings_oob(ctx: dict) -> str: root_hdr = root_header_html(ctx, oob=True) settings_oob = _oob_header_html("root-header-child", "root-settings-header-child", _settings_header_html(ctx)) content = _settings_main_panel_html(ctx) menu_html = _settings_nav_html(ctx) return oob_page(ctx, oobs_html=root_hdr + settings_oob, content_html=content, menu_html=menu_html) # ---- Cache ---- async def render_cache_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) settings_hdr = _settings_header_html(ctx) from quart import url_for as qurl cache_hdr = _sub_settings_header_html( "cache-row", "cache-header-child", qurl("settings.cache"), "refresh", "Cache", ctx, ) header_rows = root_hdr + settings_hdr + cache_hdr content = _cache_main_panel_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_cache_oob(ctx: dict) -> str: settings_hdr_oob = _settings_header_html(ctx, oob=True) from quart import url_for as qurl cache_hdr = _sub_settings_header_html( "cache-row", "cache-header-child", qurl("settings.cache"), "refresh", "Cache", ctx, ) cache_oob = _oob_header_html("root-settings-header-child", "cache-header-child", cache_hdr) content = _cache_main_panel_html(ctx) return oob_page(ctx, oobs_html=settings_hdr_oob + cache_oob, content_html=content) # ---- Snippets ---- async def render_snippets_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) settings_hdr = _settings_header_html(ctx) from quart import url_for as qurl snippets_hdr = _sub_settings_header_html( "snippets-row", "snippets-header-child", qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx, ) header_rows = root_hdr + settings_hdr + snippets_hdr content = _snippets_main_panel_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_snippets_oob(ctx: dict) -> str: settings_hdr_oob = _settings_header_html(ctx, oob=True) from quart import url_for as qurl snippets_hdr = _sub_settings_header_html( "snippets-row", "snippets-header-child", qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx, ) snippets_oob = _oob_header_html("root-settings-header-child", "snippets-header-child", snippets_hdr) content = _snippets_main_panel_html(ctx) return oob_page(ctx, oobs_html=settings_hdr_oob + snippets_oob, content_html=content) # ---- Menu items ---- async def render_menu_items_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) settings_hdr = _settings_header_html(ctx) from quart import url_for as qurl mi_hdr = _sub_settings_header_html( "menu_items-row", "menu_items-header-child", qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx, ) header_rows = root_hdr + settings_hdr + mi_hdr content = _menu_items_main_panel_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_menu_items_oob(ctx: dict) -> str: settings_hdr_oob = _settings_header_html(ctx, oob=True) from quart import url_for as qurl mi_hdr = _sub_settings_header_html( "menu_items-row", "menu_items-header-child", qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx, ) mi_oob = _oob_header_html("root-settings-header-child", "menu_items-header-child", mi_hdr) content = _menu_items_main_panel_html(ctx) return oob_page(ctx, oobs_html=settings_hdr_oob + mi_oob, content_html=content) # ---- Tag groups ---- async def render_tag_groups_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) settings_hdr = _settings_header_html(ctx) from quart import url_for as qurl tg_hdr = _sub_settings_header_html( "tag-groups-row", "tag-groups-header-child", qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx, ) header_rows = root_hdr + settings_hdr + tg_hdr content = _tag_groups_main_panel_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_tag_groups_oob(ctx: dict) -> str: settings_hdr_oob = _settings_header_html(ctx, oob=True) from quart import url_for as qurl tg_hdr = _sub_settings_header_html( "tag-groups-row", "tag-groups-header-child", qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx, ) tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child", tg_hdr) content = _tag_groups_main_panel_html(ctx) return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content) # ---- Tag group edit ---- async def render_tag_group_edit_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) settings_hdr = _settings_header_html(ctx) from quart import url_for as qurl g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None) tg_hdr = _sub_settings_header_html( "tag-groups-row", "tag-groups-header-child", qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx, ) header_rows = root_hdr + settings_hdr + tg_hdr content = _tag_groups_edit_main_panel_html(ctx) return full_page(ctx, header_rows_html=header_rows, content_html=content) async def render_tag_group_edit_oob(ctx: dict) -> str: settings_hdr_oob = _settings_header_html(ctx, oob=True) from quart import url_for as qurl g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None) tg_hdr = _sub_settings_header_html( "tag-groups-row", "tag-groups-header-child", qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx, ) tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child", tg_hdr) content = _tag_groups_edit_main_panel_html(ctx) return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content) # =========================================================================== # PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers # =========================================================================== # ---- Like toggle button (delegates to market impl) ---- def render_like_toggle_button(slug: str, liked: bool, like_url: str) -> str: """Render a like toggle button for HTMX POST response.""" from market.sexp.sexp_components import render_like_toggle_button as _market_like return _market_like(slug, liked, like_url=like_url, item_type="post") # ---- Snippets list ---- def render_snippets_list(snippets, is_admin: bool) -> str: """Render the snippets list fragment for HTMX DELETE/PATCH responses.""" from shared.browser.app.csrf import generate_csrf_token from quart import g ctx = { "snippets": snippets, "is_admin": is_admin, "csrf_token": generate_csrf_token(), } return _snippets_list_html(ctx) # ---- Menu items list + nav OOB ---- def render_menu_items_list(menu_items) -> str: """Render the menu items list fragment for HTMX responses.""" from shared.browser.app.csrf import generate_csrf_token ctx = { "menu_items": menu_items, "csrf_token": generate_csrf_token(), } return _menu_items_list_html(ctx) def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str: """Render the OOB nav update for menu items. Produces the same DOM structure as ``_types/menu_items/_nav_oob.html``: a scrolling nav wrapper with ``id="menu-items-nav-wrapper"`` and ``hx-swap-oob="outerHTML"``. """ from quart import request as qrequest if not menu_items: return sexp('(div :id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML")') # Resolve URL helpers from context or fall back to template globals if ctx is None: ctx = {} first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else "" # nav_button style (matches shared/infrastructure/jinja_setup.py) select_colours = ( "[.hover-capable_&]:hover:bg-yellow-300" " aria-selected:bg-stone-500 aria-selected:text-white" " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" ) nav_button_cls = ( f"justify-center cursor-pointer flex flex-row items-center gap-2" f" rounded bg-stone-200 text-black {select_colours} p-3" ) container_id = "menu-items-container" arrow_cls = f"scrolling-menu-arrow-{container_id}" scroll_hs = ( f"on load or scroll" f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth" f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}" f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end" ) blog_url_fn = ctx.get("blog_url") cart_url_fn = ctx.get("cart_url") app_name = ctx.get("app_name", "") item_parts = [] for item in menu_items: item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "") fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image") if item_slug == "cart" and cart_url_fn: href = cart_url_fn("/") elif blog_url_fn: href = blog_url_fn(f"/{item_slug}/") else: href = f"/{item_slug}/" selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false" img_html = sexp( '(if fi (img :src fi :alt lb :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"))', fi=fi, lb=label, ) if item_slug != "cart": item_parts.append(sexp( '(div (a :href h :hx-get hg :hx-target "#main-panel"' ' :hx-swap "outerHTML" :hx-push-url "true"' ' :aria-selected sel :class nc' ' (raw! im) (span lb)))', h=href, hg=f"/{item_slug}/", sel=selected, nc=nav_button_cls, im=img_html, lb=label, )) else: item_parts.append(sexp( '(div (a :href h :aria-selected sel :class nc' ' (raw! im) (span lb)))', h=href, sel=selected, nc=nav_button_cls, im=img_html, lb=label, )) items_html = "".join(item_parts) return sexp( '(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" :hx-swap-oob "outerHTML"' ' (button :class (str ac " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")' ' :aria-label "Scroll left"' ' :_ lhs (i :class "fa fa-chevron-left"))' ' (div :id cid' ' :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;" :_ shs' ' (div :class "flex flex-col sm:flex-row gap-1" (raw! items)))' ' (style ".scrollbar-hide::-webkit-scrollbar { display: none; }' ' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")' ' (button :class (str ac " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")' ' :aria-label "Scroll right"' ' :_ rhs (i :class "fa fa-chevron-right")))', ac=arrow_cls, cid=container_id, lhs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200", shs=scroll_hs, rhs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200", items=items_html, ) # ---- Features panel ---- def render_features_panel(features: dict, post: dict, sumup_configured: bool, sumup_merchant_code: str, sumup_checkout_prefix: str) -> str: """Render the features panel fragment for HTMX PUT responses.""" from shared.utils import host_url from quart import url_for as qurl slug = post.get("slug", "") features_url = host_url(qurl("blog.post.admin.update_features", slug=slug)) sumup_url = host_url(qurl("blog.post.admin.update_sumup", slug=slug)) hs_trigger = "on change trigger submit on closest
" form_html = sexp( '(form :hx-put fu :hx-target "#features-panel" :hx-swap "outerHTML"' ' :hx-headers "{\\\"Content-Type\\\": \\\"application/json\\\"}" :hx-ext "json-enc" :class "space-y-3"' ' (label :class "flex items-center gap-3 cursor-pointer"' ' (input :type "checkbox" :name "calendar" :value "true" :checked cc' ' :class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"' ' :_ ht)' ' (span :class "text-sm text-stone-700"' ' (i :class "fa fa-calendar text-blue-600 mr-1")' ' " Calendar \u2014 enable event booking on this page"))' ' (label :class "flex items-center gap-3 cursor-pointer"' ' (input :type "checkbox" :name "market" :value "true" :checked mc' ' :class "h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"' ' :_ ht)' ' (span :class "text-sm text-stone-700"' ' (i :class "fa fa-shopping-bag text-green-600 mr-1")' ' " Market \u2014 enable product catalog on this page")))', fu=features_url, cc=bool(features.get("calendar")), mc=bool(features.get("market")), ht=hs_trigger, ) sumup_html = "" if features.get("calendar") or features.get("market"): placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..." connected = sexp( '(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected")', ) if sumup_configured else "" key_hint = sexp( '(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")', ) if sumup_configured else "" sumup_html = sexp( '(div :class "mt-4 pt-4 border-t border-stone-100"' ' (h4 :class "text-sm font-medium text-stone-700"' ' (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 :hx-put su :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3"' ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")' ' (input :type "text" :name "merchant_code" :value smc :placeholder "e.g. ME4J6100"' ' :class "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 ph' ' :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")' ' (raw! kh))' ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")' ' (input :type "text" :name "checkout_prefix" :value scp :placeholder "e.g. ROSE-"' ' :class "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")' ' (raw! cn)))', su=sumup_url, smc=sumup_merchant_code, ph=placeholder, kh=key_hint, scp=sumup_checkout_prefix, cn=connected, ) return sexp( '(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"' ' (h3 :class "text-lg font-semibold text-stone-800" "Page Features")' ' (raw! fh) (raw! sh))', fh=form_html, sh=sumup_html, ) # ---- Markets panel ---- def render_markets_panel(markets, post: dict) -> str: """Render the markets panel fragment for HTMX responses.""" from shared.utils import host_url from quart import url_for as qurl slug = post.get("slug", "") create_url = host_url(qurl("blog.post.admin.create_market", slug=slug)) list_html = "" if markets: li_parts = [] for m in markets: m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug)) li_parts.append(sexp( '(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"' ' (div (span :class "font-medium" mn)' ' (span :class "text-stone-400 text-sm ml-2" (str "/" ms "/")))' ' (button :hx-delete du :hx-target "#markets-panel" :hx-swap "outerHTML"' ' :hx-confirm cf :class "text-red-600 hover:text-red-800 text-sm" "Delete"))', mn=m_name, ms=m_slug, du=del_url, cf=f"Delete market '{m_name}'?", )) list_html = sexp( '(ul :class "space-y-2 mb-4" (raw! items))', items="".join(li_parts), ) else: list_html = sexp('(p :class "text-stone-500 mb-4 text-sm" "No markets yet.")') return sexp( '(div :id "markets-panel"' ' (h3 :class "text-lg font-semibold mb-3" "Markets")' ' (raw! lh)' ' (form :hx-post cu :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2"' ' (input :type "text" :name "name" :placeholder "Market name" :required ""' ' :class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")' ' (button :type "submit"' ' :class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create")))', lh=list_html, cu=create_url, ) # ---- Associated entries ---- def render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str: """Render the associated entries panel for HTMX POST responses.""" from shared.browser.app.csrf import generate_csrf_token from quart import url_for as qurl from shared.utils import host_url csrf = generate_csrf_token() has_entries = False entry_items: list[str] = [] for calendar in all_calendars: entries = getattr(calendar, "entries", []) or [] cal_name = getattr(calendar, "name", "") cal_post = getattr(calendar, "post", None) cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None cal_title = getattr(cal_post, "title", "") if cal_post else "" for entry in entries: e_id = getattr(entry, "id", None) if e_id not in associated_entry_ids: continue if getattr(entry, "deleted_at", None) is not None: continue has_entries = True e_name = getattr(entry, "name", "") e_start = getattr(entry, "start_at", None) e_end = getattr(entry, "end_at", None) toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id)) img_html = sexp( '(if fi (img :src fi :alt ct :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"))', fi=cal_fi, ct=cal_title, ) date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else "" if e_end: date_str += f" \u2013 {e_end.strftime('%H:%M')}" entry_items.append(sexp( '(button :type "button"' ' :class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"' ' :data-confirm "" :data-confirm-title "Remove entry?"' ' :data-confirm-text ct :data-confirm-icon "warning"' ' :data-confirm-confirm-text "Yes, remove it"' ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' ' :hx-post tu :hx-trigger "confirmed"' ' :hx-target "#associated-entries-list" :hx-swap "outerHTML"' ' :hx-headers hh' ' :_ "on htmx:afterRequest trigger entryToggled on body"' ' (div :class "flex items-center justify-between gap-3"' ' (raw! im)' ' (div :class "flex-1"' ' (div :class "font-medium text-sm" en)' ' (div :class "text-xs text-stone-600 mt-1" ds))' ' (i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0")))', ct=f"This will remove {e_name} from this post", tu=toggle_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', im=img_html, en=e_name, ds=f"{cal_name} \u2022 {date_str}", )) if has_entries: content_html = sexp( '(div :class "space-y-1" (raw! items))', items="".join(entry_items), ) else: content_html = sexp( '(div :class "text-sm text-stone-400"' ' "No entries associated yet. Browse calendars below to add entries.")', ) return sexp( '(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"' ' (h3 :class "text-lg font-semibold mb-4" "Associated Entries")' ' (raw! ch))', ch=content_html, ) # ---- Nav entries OOB ---- def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict | None = None) -> str: """Render the OOB nav entries swap. Produces the ``entries-calendars-nav-wrapper`` OOB element with links to associated entries and calendars. """ if ctx is None: ctx = {} entries_list = [] if associated_entries and hasattr(associated_entries, "entries"): entries_list = associated_entries.entries or [] has_items = bool(entries_list or calendars) if not has_items: return sexp('(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")') events_url_fn = ctx.get("events_url") # nav_button_less_pad style select_colours = ( "[.hover-capable_&]:hover:bg-yellow-300" " aria-selected:bg-stone-500 aria-selected:text-white" " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" ) nav_cls = ( f"justify-center cursor-pointer flex flex-row items-center gap-2" f" rounded bg-stone-200 text-black {select_colours} p-2" ) post_slug = post.get("slug", "") scroll_hs = ( "on load or scroll" " if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth" " remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow" " else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end" ) item_parts = [] # Entry links for entry in entries_list: e_name = getattr(entry, "name", "") e_start = getattr(entry, "start_at", None) e_end = getattr(entry, "end_at", None) cal_slug = getattr(entry, "calendar_slug", "") if e_start: entry_path = ( f"/{post_slug}/calendars/{cal_slug}/" f"{e_start.year}/{e_start.month}/{e_start.day}" f"/entries/{getattr(entry, 'id', '')}/" ) date_str = e_start.strftime("%b %d, %Y at %H:%M") if e_end: date_str += f" \u2013 {e_end.strftime('%H:%M')}" else: entry_path = f"/{post_slug}/calendars/{cal_slug}/" date_str = "" href = events_url_fn(entry_path) if events_url_fn else entry_path item_parts.append(sexp( '(a :href h :class nc' ' (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" en)' ' (div :class "text-xs text-stone-600 truncate" ds)))', h=href, nc=nav_cls, en=e_name, ds=date_str, )) # Calendar links for calendar in (calendars or []): cal_name = getattr(calendar, "name", "") cal_slug = getattr(calendar, "slug", "") cal_path = f"/{post_slug}/calendars/{cal_slug}/" href = events_url_fn(cal_path) if events_url_fn else cal_path item_parts.append(sexp( '(a :href h :class nc' ' (i :class "fa fa-calendar" :aria-hidden "true")' ' (div cn))', h=href, nc=nav_cls, cn=cal_name, )) items_html = "".join(item_parts) return sexp( '(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" :hx-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;" :_ shs' ' (div :class "flex flex-col sm:flex-row gap-1" (raw! 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")))', shs=scroll_hs, items=items_html, )