Market: blog links now use market_url('/{slug}/') instead of
events_url('/{slug}/markets/'), matching the market service's
actual route structure /<page_slug>/<market_slug>/.
Calendar: flatten route from /<slug>/calendars/<calendar_slug>/
to /<slug>/<calendar_slug>/ by changing the events app blueprint
prefix and moving listing routes to explicit /calendars/ paths.
Update all hardcoded calendar URL paths across blog and events
services (Python + Jinja templates).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2636 lines
113 KiB
Python
2636 lines
113 KiB
Python
"""
|
|
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 / market services
|
|
events_url_fn = ctx.get("events_url")
|
|
market_url_fn = ctx.get("market_url")
|
|
if callable(events_url_fn):
|
|
for url_fn, path, label in [
|
|
(events_url_fn, f"/{slug}/calendar/", "calendar"),
|
|
(market_url_fn, f"/{slug}/", "markets"),
|
|
(events_url_fn, f"/{slug}/payments/", "payments"),
|
|
]:
|
|
if not callable(url_fn):
|
|
continue
|
|
href = 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
|
|
init_js = (
|
|
"(function() {\n"
|
|
" function applyEditorFontSize() {\n"
|
|
" document.documentElement.style.fontSize = '62.5%';\n"
|
|
" document.body.style.fontSize = '1.6rem';\n"
|
|
" }\n"
|
|
" function restoreDefaultFontSize() {\n"
|
|
" document.documentElement.style.fontSize = '';\n"
|
|
" document.body.style.fontSize = '';\n"
|
|
" }\n"
|
|
" applyEditorFontSize();\n"
|
|
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {\n"
|
|
" if (e.detail.target && e.detail.target.id === 'main-panel') {\n"
|
|
" restoreDefaultFontSize();\n"
|
|
" document.body.removeEventListener('htmx:beforeSwap', cleanup);\n"
|
|
" }\n"
|
|
" });\n"
|
|
"\n"
|
|
" function init() {\n"
|
|
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
|
|
f" var uploadUrl = '{upload_image_url}';\n"
|
|
" var uploadUrls = {\n"
|
|
" image: uploadUrl,\n"
|
|
f" media: '{upload_media_url}',\n"
|
|
f" file: '{upload_file_url}',\n"
|
|
" };\n"
|
|
"\n"
|
|
" var fileInput = document.getElementById('feature-image-file');\n"
|
|
" var addBtn = document.getElementById('feature-image-add-btn');\n"
|
|
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
|
|
" var preview = document.getElementById('feature-image-preview');\n"
|
|
" var emptyState = document.getElementById('feature-image-empty');\n"
|
|
" var filledState = document.getElementById('feature-image-filled');\n"
|
|
" var hiddenUrl = document.getElementById('feature-image-input');\n"
|
|
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
|
|
" var captionInput = document.getElementById('feature-image-caption');\n"
|
|
" var uploading = document.getElementById('feature-image-uploading');\n"
|
|
"\n"
|
|
" function showFilled(url) {\n"
|
|
" preview.src = url;\n"
|
|
" hiddenUrl.value = url;\n"
|
|
" emptyState.classList.add('hidden');\n"
|
|
" filledState.classList.remove('hidden');\n"
|
|
" uploading.classList.add('hidden');\n"
|
|
" }\n"
|
|
"\n"
|
|
" function showEmpty() {\n"
|
|
" preview.src = '';\n"
|
|
" hiddenUrl.value = '';\n"
|
|
" hiddenCaption.value = '';\n"
|
|
" captionInput.value = '';\n"
|
|
" emptyState.classList.remove('hidden');\n"
|
|
" filledState.classList.add('hidden');\n"
|
|
" uploading.classList.add('hidden');\n"
|
|
" }\n"
|
|
"\n"
|
|
" function uploadFile(file) {\n"
|
|
" emptyState.classList.add('hidden');\n"
|
|
" uploading.classList.remove('hidden');\n"
|
|
" var fd = new FormData();\n"
|
|
" fd.append('file', file);\n"
|
|
" fetch(uploadUrl, {\n"
|
|
" method: 'POST',\n"
|
|
" body: fd,\n"
|
|
" headers: { 'X-CSRFToken': csrfToken },\n"
|
|
" })\n"
|
|
" .then(function(r) {\n"
|
|
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
|
|
" return r.json();\n"
|
|
" })\n"
|
|
" .then(function(data) {\n"
|
|
" var url = data.images && data.images[0] && data.images[0].url;\n"
|
|
" if (url) showFilled(url);\n"
|
|
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
|
|
" })\n"
|
|
" .catch(function(e) {\n"
|
|
" showEmpty();\n"
|
|
" alert(e.message);\n"
|
|
" });\n"
|
|
" }\n"
|
|
"\n"
|
|
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
|
|
" preview.addEventListener('click', function() { fileInput.click(); });\n"
|
|
" deleteBtn.addEventListener('click', function(e) {\n"
|
|
" e.stopPropagation();\n"
|
|
" showEmpty();\n"
|
|
" });\n"
|
|
" fileInput.addEventListener('change', function() {\n"
|
|
" if (fileInput.files && fileInput.files[0]) {\n"
|
|
" uploadFile(fileInput.files[0]);\n"
|
|
" fileInput.value = '';\n"
|
|
" }\n"
|
|
" });\n"
|
|
" captionInput.addEventListener('input', function() {\n"
|
|
" hiddenCaption.value = captionInput.value;\n"
|
|
" });\n"
|
|
"\n"
|
|
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
|
|
" function autoResize() {\n"
|
|
" excerpt.style.height = 'auto';\n"
|
|
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
|
|
" }\n"
|
|
" excerpt.addEventListener('input', autoResize);\n"
|
|
" autoResize();\n"
|
|
"\n"
|
|
" window.mountEditor('lexical-editor', {\n"
|
|
" initialJson: null,\n"
|
|
" csrfToken: csrfToken,\n"
|
|
" uploadUrls: uploadUrls,\n"
|
|
f" oembedUrl: '{oembed_url}',\n"
|
|
f" unsplashApiKey: '{unsplash_key}',\n"
|
|
f" snippetsUrl: '{snippets_url}',\n"
|
|
" });\n"
|
|
"\n"
|
|
" document.addEventListener('keydown', function(e) {\n"
|
|
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
|
|
" e.preventDefault();\n"
|
|
" document.getElementById('post-new-form').requestSubmit();\n"
|
|
" }\n"
|
|
" });\n"
|
|
" }\n"
|
|
"\n"
|
|
" if (typeof window.mountEditor === 'function') {\n"
|
|
" init();\n"
|
|
" } else {\n"
|
|
" var _t = setInterval(function() {\n"
|
|
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
|
|
" }, 50);\n"
|
|
" }\n"
|
|
"})();\n"
|
|
)
|
|
parts.append(sexp(
|
|
'(<> (script :src ejs) (script (raw! js)))',
|
|
ejs=editor_js, js=init_js,
|
|
))
|
|
|
|
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/>"
|
|
|
|
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}/{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}/{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}/{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,
|
|
)
|