Compare commits
5 Commits
984e2ebed0
...
c2fe142039
| Author | SHA1 | Date | |
|---|---|---|---|
| c2fe142039 | |||
| f0fbcef3f6 | |||
| d7f9afff8e | |||
| f2910ad767 | |||
| e75c8d16d1 |
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from .services.pages_data import pages_data
|
|||||||
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from shared.browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
def register(url_prefix, title):
|
def register(url_prefix, title):
|
||||||
@@ -62,6 +62,19 @@ def register(url_prefix, title):
|
|||||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _render_new_post_page(tctx):
|
||||||
|
"""Compose a full page with blog header for new post/page creation."""
|
||||||
|
from shared.sx.helpers import root_header_sx, full_page_sx
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
blog_hdr = await render_to_sx("menu-row-sx",
|
||||||
|
id="blog-row", level=1,
|
||||||
|
link_label_content=SxExpr("(div)"),
|
||||||
|
child_id="blog-header-child")
|
||||||
|
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||||
|
content = tctx.get("editor_html", "")
|
||||||
|
return await full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||||
|
|
||||||
SORT_MAP = {
|
SORT_MAP = {
|
||||||
"newest": "published_at DESC",
|
"newest": "published_at DESC",
|
||||||
"oldest": "published_at ASC",
|
"oldest": "published_at ASC",
|
||||||
@@ -118,100 +131,83 @@ def register(url_prefix, title):
|
|||||||
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_home_page, render_home_oob
|
from shared.sx.helpers import (
|
||||||
|
render_to_sx, root_header_sx, full_page_sx, oob_page_sx,
|
||||||
|
post_header_sx, oob_header_sx, mobile_menu_sx,
|
||||||
|
post_mobile_nav_sx, mobile_root_nav_sx,
|
||||||
|
)
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx.update(ctx)
|
tctx.update(ctx)
|
||||||
|
|
||||||
|
post = ctx.get("post", {})
|
||||||
|
content = await render_to_sx("blog-home-main",
|
||||||
|
html_content=post.get("html", ""),
|
||||||
|
sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None)
|
||||||
|
meta_data = services.get("blog_page").post_meta_data(post, ctx.get("base_title", ""))
|
||||||
|
meta = await render_to_sx("blog-meta", **meta_data)
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_home_page(tctx)
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
post_hdr = await post_header_sx(tctx)
|
||||||
|
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||||
|
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
|
||||||
|
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
|
||||||
|
meta=meta, menu=menu)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
else:
|
else:
|
||||||
sx_src = await render_home_oob(tctx)
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
post_hdr = await post_header_sx(tctx)
|
||||||
|
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||||
|
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
|
||||||
|
sx_src = await oob_page_sx(oobs=header_oob, content=content)
|
||||||
return sx_response(sx_src)
|
return sx_response(sx_src)
|
||||||
|
|
||||||
@blogs_bp.get("/index")
|
@blogs_bp.get("/index")
|
||||||
@blogs_bp.get("/index/")
|
@blogs_bp.get("/index/")
|
||||||
async def index():
|
async def index():
|
||||||
"""Blog listing — moved from / to /index."""
|
"""Blog listing — moved from / to /index."""
|
||||||
|
from shared.services.registry import services
|
||||||
q = decode()
|
from shared.sx.helpers import (
|
||||||
content_type = request.args.get("type", "posts")
|
render_to_sx, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx,
|
||||||
|
|
||||||
if content_type == "pages":
|
|
||||||
data = await pages_data(g.s, q.page, q.search)
|
|
||||||
context = {
|
|
||||||
**data,
|
|
||||||
"content_type": "pages",
|
|
||||||
"search": q.search,
|
|
||||||
"selected_tags": (),
|
|
||||||
"selected_authors": (),
|
|
||||||
"selected_groups": (),
|
|
||||||
"sort": None,
|
|
||||||
"view": None,
|
|
||||||
"drafts": None,
|
|
||||||
"draft_count": 0,
|
|
||||||
"tags": [],
|
|
||||||
"authors": [],
|
|
||||||
"tag_groups": [],
|
|
||||||
"posts": data.get("pages", []),
|
|
||||||
}
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_page_cards
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx.update(context)
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_blog_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
elif q.page > 1:
|
|
||||||
sx_src = await render_blog_page_cards(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
else:
|
|
||||||
sx_src = await render_blog_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
# Default: posts listing
|
|
||||||
# Drafts filter requires login; ignore if not logged in
|
|
||||||
show_drafts = bool(q.drafts and g.user)
|
|
||||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
|
||||||
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
|
|
||||||
|
|
||||||
# For the draft count badge: admin sees all drafts, non-admin sees own
|
|
||||||
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
|
|
||||||
|
|
||||||
data = await posts_data(
|
|
||||||
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
|
|
||||||
drafts=show_drafts, drafts_user_id=drafts_user_id,
|
|
||||||
count_drafts_for_user_id=count_drafts_uid,
|
|
||||||
selected_groups=q.selected_groups,
|
|
||||||
)
|
)
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
|
||||||
context = {
|
async def _blog_hdr(ctx, oob=False):
|
||||||
**data,
|
return await render_to_sx("menu-row-sx",
|
||||||
"content_type": "posts",
|
id="blog-row", level=1,
|
||||||
"selected_tags": q.selected_tags,
|
link_label_content=SxExpr("(div)"),
|
||||||
"selected_authors": q.selected_authors,
|
child_id="blog-header-child", oob=oob)
|
||||||
"selected_groups": q.selected_groups,
|
|
||||||
"sort": q.sort,
|
data = await services.get("blog_page").index_data(g.s)
|
||||||
"search": q.search,
|
|
||||||
"view": q.view,
|
# Render content, aside, and filter via .sx defcomps
|
||||||
"drafts": q.drafts if show_drafts else None,
|
content = await render_to_sx("blog-index-main-content", **data)
|
||||||
}
|
aside = await render_to_sx("blog-index-aside-content", **data)
|
||||||
|
filter_sx = await render_to_sx("blog-index-filter-content", **data)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx.update(context)
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_blog_page(tctx)
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
blog_hdr = await _blog_hdr(tctx)
|
||||||
|
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||||
|
html = await full_page_sx(tctx, header_rows=header_rows,
|
||||||
|
content=content, aside=aside, filter=filter_sx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
elif q.page > 1:
|
elif data.get("page", 1) > 1:
|
||||||
# Sx wire format — client renders blog cards
|
# Pagination — return just the cards
|
||||||
sx_src = await render_blog_cards(tctx)
|
return sx_response(content)
|
||||||
return sx_response(sx_src)
|
|
||||||
else:
|
else:
|
||||||
sx_src = await render_blog_oob(tctx)
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
blog_hdr = await _blog_hdr(tctx)
|
||||||
|
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||||
|
header_oob = await oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||||
|
sx_src = await oob_page_sx(oobs=header_oob, content=content,
|
||||||
|
aside=aside, filter=filter_sx)
|
||||||
return sx_response(sx_src)
|
return sx_response(sx_src)
|
||||||
|
|
||||||
@blogs_bp.post("/new/")
|
@blogs_bp.post("/new/")
|
||||||
@@ -233,19 +229,19 @@ def register(url_prefix, title):
|
|||||||
lexical_doc = json.loads(lexical_raw)
|
lexical_doc = json.loads(lexical_raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
|
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
ok, reason = validate_lexical(lexical_doc)
|
ok, reason = validate_lexical(lexical_doc)
|
||||||
if not ok:
|
if not ok:
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error=reason)
|
tctx["editor_html"] = await render_editor_panel(save_error=reason)
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
# Create directly in db_blog
|
# Create directly in db_blog
|
||||||
@@ -289,21 +285,21 @@ def register(url_prefix, title):
|
|||||||
lexical_doc = json.loads(lexical_raw)
|
lexical_doc = json.loads(lexical_raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||||
tctx["is_page"] = True
|
tctx["is_page"] = True
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
ok, reason = validate_lexical(lexical_doc)
|
ok, reason = validate_lexical(lexical_doc)
|
||||||
if not ok:
|
if not ok:
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
|
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
|
||||||
tctx["is_page"] = True
|
tctx["is_page"] = True
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
# Create directly in db_blog
|
# Create directly in db_blog
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, make_response, request, jsonify, g
|
from quart import Blueprint, make_response, request, jsonify, g, url_for
|
||||||
|
|
||||||
from shared.browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from .services.menu_items import (
|
from .services.menu_items import (
|
||||||
@@ -12,22 +12,217 @@ from .services.menu_items import (
|
|||||||
search_pages,
|
search_pages,
|
||||||
MenuItemError,
|
MenuItemError,
|
||||||
)
|
)
|
||||||
from shared.sx.helpers import sx_response
|
from markupsafe import escape
|
||||||
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_menu_items_list(menu_items):
|
||||||
|
"""Serialize ORM menu items and render via .sx defcomp."""
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
items = []
|
||||||
|
for item in menu_items:
|
||||||
|
items.append({
|
||||||
|
"feature_image": getattr(item, "feature_image", None),
|
||||||
|
"label": getattr(item, "label", "") or "",
|
||||||
|
"url": getattr(item, "url", "") or "",
|
||||||
|
"sort_order": getattr(item, "position", 0) or 0,
|
||||||
|
"edit_url": url_for("menu_items.edit_menu_item", item_id=item.id),
|
||||||
|
"delete_url": url_for("menu_items.delete_menu_item_route", item_id=item.id),
|
||||||
|
})
|
||||||
|
new_url = url_for("menu_items.new_menu_item")
|
||||||
|
return await render_to_sx("blog-menu-items-content",
|
||||||
|
menu_items=items, new_url=new_url, csrf=csrf)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_menu_item_form(menu_item=None) -> str:
|
||||||
|
"""Render menu item add/edit form."""
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
search_url = url_for("menu_items.search_pages_route")
|
||||||
|
is_edit = menu_item is not None
|
||||||
|
|
||||||
|
if is_edit:
|
||||||
|
action_url = url_for("menu_items.update_menu_item_route", item_id=menu_item.id)
|
||||||
|
action_attr = f'sx-put="{action_url}"'
|
||||||
|
post_id = str(menu_item.container_id) if menu_item.container_id else ""
|
||||||
|
label = getattr(menu_item, "label", "") or ""
|
||||||
|
slug = getattr(menu_item, "slug", "") or ""
|
||||||
|
fi = getattr(menu_item, "feature_image", None) or ""
|
||||||
|
else:
|
||||||
|
action_url = url_for("menu_items.create_menu_item_route")
|
||||||
|
action_attr = f'sx-post="{action_url}"'
|
||||||
|
post_id = ""
|
||||||
|
label = ""
|
||||||
|
slug = ""
|
||||||
|
fi = ""
|
||||||
|
|
||||||
|
if post_id:
|
||||||
|
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
|
||||||
|
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
|
||||||
|
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
|
||||||
|
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
|
||||||
|
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
|
||||||
|
else:
|
||||||
|
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
|
||||||
|
|
||||||
|
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
|
||||||
|
title = "Edit Menu Item" if is_edit else "Add Menu Item"
|
||||||
|
|
||||||
|
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">{title}</h2>
|
||||||
|
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
|
||||||
|
<i class="fa fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
|
||||||
|
{selected}
|
||||||
|
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
|
||||||
|
sx-include="#selected-post-id"
|
||||||
|
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
|
||||||
|
class="space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{csrf}">
|
||||||
|
<div class="flex gap-2 pb-3 border-b">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
|
<i class="fa fa-save"></i> Save</button>
|
||||||
|
<button type="button" onclick="{close_js}"
|
||||||
|
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
|
||||||
|
<input type="text" placeholder="Search for a page... (or leave blank for all)"
|
||||||
|
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
|
||||||
|
sx-target="#page-search-results" sx-swap="innerHTML"
|
||||||
|
name="q" id="page-search-input"
|
||||||
|
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
<div id="page-search-results" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function(e) {{
|
||||||
|
var pageOption = e.target.closest('[data-page-id]');
|
||||||
|
if (pageOption) {{
|
||||||
|
var postId = pageOption.dataset.pageId;
|
||||||
|
var postTitle = pageOption.dataset.pageTitle;
|
||||||
|
var postSlug = pageOption.dataset.pageSlug;
|
||||||
|
var postImage = pageOption.dataset.pageImage;
|
||||||
|
document.getElementById('selected-post-id').value = postId;
|
||||||
|
var display = document.getElementById('selected-page-display');
|
||||||
|
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
|
||||||
|
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
|
||||||
|
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
|
||||||
|
display.classList.remove('hidden');
|
||||||
|
document.getElementById('page-search-results').innerHTML = '';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
</script>'''
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_page_search_results(pages, query, page, has_more) -> str:
|
||||||
|
"""Render page search results."""
|
||||||
|
if not pages and query:
|
||||||
|
return await render_to_sx("page-search-empty", query=query)
|
||||||
|
if not pages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for post in pages:
|
||||||
|
items.append(await render_to_sx("page-search-item",
|
||||||
|
id=post.id, title=post.title,
|
||||||
|
slug=post.slug,
|
||||||
|
feature_image=post.feature_image or None))
|
||||||
|
|
||||||
|
sentinel = ""
|
||||||
|
if has_more:
|
||||||
|
search_url = url_for("menu_items.search_pages_route")
|
||||||
|
sentinel = await render_to_sx("page-search-sentinel",
|
||||||
|
url=search_url, query=query,
|
||||||
|
next_page=page + 1)
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(items) + ")"
|
||||||
|
return await render_to_sx("page-search-results",
|
||||||
|
items=SxExpr(items_sx),
|
||||||
|
sentinel=SxExpr(sentinel) if sentinel else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_menu_items_nav_oob(menu_items) -> str:
|
||||||
|
"""Render OOB nav update for menu items."""
|
||||||
|
from quart import request as qrequest
|
||||||
|
|
||||||
|
if not menu_items:
|
||||||
|
return await render_to_sx("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
|
||||||
|
|
||||||
|
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
href = f"/{item_slug}/"
|
||||||
|
selected = "true" if item_slug == first_seg else "false"
|
||||||
|
|
||||||
|
img_sx = await render_to_sx("img-or-placeholder", src=fi, alt=label,
|
||||||
|
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||||
|
|
||||||
|
if item_slug != "cart":
|
||||||
|
item_parts.append(await render_to_sx("blog-nav-item-link",
|
||||||
|
href=href, hx_get=f"/{item_slug}/", selected=selected,
|
||||||
|
nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
item_parts.append(await render_to_sx("blog-nav-item-plain",
|
||||||
|
href=href, selected=selected, nav_cls=nav_button_cls,
|
||||||
|
img=SxExpr(img_sx), label=label,
|
||||||
|
))
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||||
|
|
||||||
|
return await render_to_sx("scroll-nav-wrapper",
|
||||||
|
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
|
||||||
|
arrow_cls=arrow_cls,
|
||||||
|
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
|
||||||
|
scroll_hs=scroll_hs,
|
||||||
|
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
|
||||||
|
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||||
|
|
||||||
async def get_menu_items_nav_oob_async(menu_items):
|
async def get_menu_items_nav_oob_async(menu_items):
|
||||||
"""Helper to generate OOB update for root nav menu items"""
|
"""Helper to generate OOB update for root nav menu items"""
|
||||||
from sx.sx_components import render_menu_items_nav_oob
|
return await _render_menu_items_nav_oob(menu_items)
|
||||||
return await render_menu_items_nav_oob(menu_items)
|
|
||||||
|
|
||||||
@bp.get("/new/")
|
@bp.get("/new/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_menu_item():
|
async def new_menu_item():
|
||||||
"""Show form to create new menu item"""
|
"""Show form to create new menu item"""
|
||||||
from sx.sx_components import render_menu_item_form
|
return sx_response(_render_menu_item_form())
|
||||||
return sx_response(render_menu_item_form())
|
|
||||||
|
|
||||||
@bp.post("/")
|
@bp.post("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -50,8 +245,7 @@ def register():
|
|||||||
|
|
||||||
# Get updated list and nav OOB
|
# Get updated list and nav OOB
|
||||||
menu_items = await get_all_menu_items(g.s)
|
menu_items = await get_all_menu_items(g.s)
|
||||||
from sx.sx_components import render_menu_items_list
|
html = await _render_menu_items_list(menu_items)
|
||||||
html = await render_menu_items_list(menu_items)
|
|
||||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||||
return sx_response(html + nav_oob)
|
return sx_response(html + nav_oob)
|
||||||
|
|
||||||
@@ -66,8 +260,7 @@ def register():
|
|||||||
if not menu_item:
|
if not menu_item:
|
||||||
return await make_response("Menu item not found", 404)
|
return await make_response("Menu item not found", 404)
|
||||||
|
|
||||||
from sx.sx_components import render_menu_item_form
|
return sx_response(_render_menu_item_form(menu_item=menu_item))
|
||||||
return sx_response(render_menu_item_form(menu_item=menu_item))
|
|
||||||
|
|
||||||
@bp.put("/<int:item_id>/")
|
@bp.put("/<int:item_id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -90,8 +283,7 @@ def register():
|
|||||||
|
|
||||||
# Get updated list and nav OOB
|
# Get updated list and nav OOB
|
||||||
menu_items = await get_all_menu_items(g.s)
|
menu_items = await get_all_menu_items(g.s)
|
||||||
from sx.sx_components import render_menu_items_list
|
html = await _render_menu_items_list(menu_items)
|
||||||
html = await render_menu_items_list(menu_items)
|
|
||||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||||
return sx_response(html + nav_oob)
|
return sx_response(html + nav_oob)
|
||||||
|
|
||||||
@@ -111,8 +303,7 @@ def register():
|
|||||||
|
|
||||||
# Get updated list and nav OOB
|
# Get updated list and nav OOB
|
||||||
menu_items = await get_all_menu_items(g.s)
|
menu_items = await get_all_menu_items(g.s)
|
||||||
from sx.sx_components import render_menu_items_list
|
html = await _render_menu_items_list(menu_items)
|
||||||
html = await render_menu_items_list(menu_items)
|
|
||||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||||
return sx_response(html + nav_oob)
|
return sx_response(html + nav_oob)
|
||||||
|
|
||||||
@@ -127,8 +318,7 @@ def register():
|
|||||||
pages, total = await search_pages(g.s, query, page, per_page)
|
pages, total = await search_pages(g.s, query, page, per_page)
|
||||||
has_more = (page * per_page) < total
|
has_more = (page * per_page) < total
|
||||||
|
|
||||||
from sx.sx_components import render_page_search_results
|
return sx_response(await _render_page_search_results(pages, query, page, has_more))
|
||||||
return sx_response(await render_page_search_results(pages, query, page, has_more))
|
|
||||||
|
|
||||||
@bp.post("/reorder/")
|
@bp.post("/reorder/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -152,8 +342,7 @@ def register():
|
|||||||
|
|
||||||
# Get updated list and nav OOB
|
# Get updated list and nav OOB
|
||||||
menu_items = await get_all_menu_items(g.s)
|
menu_items = await get_all_menu_items(g.s)
|
||||||
from sx.sx_components import render_menu_items_list
|
html = await _render_menu_items_list(menu_items)
|
||||||
html = await render_menu_items_list(menu_items)
|
|
||||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||||
return sx_response(html + nav_oob)
|
return sx_response(html + nav_oob)
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from shared.browser.app.authz import require_admin, require_post_author
|
from shared.browser.app.authz import require_admin, require_post_author
|
||||||
from shared.sx.helpers import sx_response
|
from markupsafe import escape
|
||||||
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
|
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_html_sx(html: str) -> str:
|
||||||
|
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
return "(raw! " + sx_serialize(html) + ")"
|
||||||
|
|
||||||
def _post_to_edit_dict(post) -> dict:
|
def _post_to_edit_dict(post) -> dict:
|
||||||
"""Convert an ORM Post to a dict matching the shape templates expect.
|
"""Convert an ORM Post to a dict matching the shape templates expect.
|
||||||
|
|
||||||
@@ -51,6 +60,262 @@ def _post_to_edit_dict(post) -> dict:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_features(features, post, result):
|
||||||
|
"""Render features panel via .sx defcomp."""
|
||||||
|
slug = post.get("slug", "")
|
||||||
|
return await render_to_sx("blog-features-panel-content",
|
||||||
|
features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)),
|
||||||
|
calendar_checked=bool(features.get("calendar")),
|
||||||
|
market_checked=bool(features.get("market")),
|
||||||
|
show_sumup=bool(features.get("calendar") or features.get("market")),
|
||||||
|
sumup_url=host_url(url_for("blog.post.admin.update_sumup", slug=slug)),
|
||||||
|
merchant_code=result.get("sumup_merchant_code") or "",
|
||||||
|
placeholder="\u2022" * 8 if result.get("sumup_configured") else "sup_sk_...",
|
||||||
|
sumup_configured=result.get("sumup_configured", False),
|
||||||
|
checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_markets(markets, slug):
|
||||||
|
"""Serialize ORM/DTO market objects to dicts for .sx defcomp."""
|
||||||
|
result = []
|
||||||
|
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", "")
|
||||||
|
result.append({
|
||||||
|
"name": m_name, "slug": m_slug,
|
||||||
|
"delete_url": host_url(url_for("blog.post.admin.delete_market",
|
||||||
|
slug=slug, market_slug=m_slug)),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _render_calendar_view(
|
||||||
|
calendar, year, month, month_name, weekday_names, weeks,
|
||||||
|
prev_month, prev_month_year, next_month, next_month_year,
|
||||||
|
prev_year, next_year, month_entries, associated_entry_ids,
|
||||||
|
post_slug: str,
|
||||||
|
) -> str:
|
||||||
|
"""Build calendar month grid HTML."""
|
||||||
|
from quart import url_for as qurl
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
esc = escape
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
cal_id = calendar.id
|
||||||
|
|
||||||
|
def cal_url(y, m):
|
||||||
|
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
|
||||||
|
|
||||||
|
cur_url = cal_url(year, month)
|
||||||
|
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
|
||||||
|
|
||||||
|
nav = (
|
||||||
|
f'<header class="flex items-center justify-center mb-4">'
|
||||||
|
f'<nav class="flex items-center gap-2 text-xl">'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">«</a>'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">‹</a>'
|
||||||
|
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">›</a>'
|
||||||
|
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">»</a>'
|
||||||
|
f'</nav></header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
|
||||||
|
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
|
||||||
|
|
||||||
|
cells: list[str] = []
|
||||||
|
for week in weeks:
|
||||||
|
for day in week:
|
||||||
|
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
|
||||||
|
day_date = day.date
|
||||||
|
|
||||||
|
entry_btns: list[str] = []
|
||||||
|
for e in month_entries:
|
||||||
|
e_start = getattr(e, "start_at", None)
|
||||||
|
if not e_start or e_start.date() != day_date:
|
||||||
|
continue
|
||||||
|
e_id = getattr(e, "id", None)
|
||||||
|
e_name = esc(getattr(e, "name", ""))
|
||||||
|
t_url = toggle_url_fn(e_id)
|
||||||
|
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
|
||||||
|
|
||||||
|
if e_id in associated_entry_ids:
|
||||||
|
entry_btns.append(
|
||||||
|
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
|
||||||
|
f'<span class="truncate flex-1">{e_name}</span>'
|
||||||
|
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
|
||||||
|
f' data-confirm data-confirm-title="Remove entry?"'
|
||||||
|
f' data-confirm-text="Remove {e_name} from this post?"'
|
||||||
|
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
|
||||||
|
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||||
|
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||||
|
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||||
|
f""" sx-headers='{hx_hdrs}'"""
|
||||||
|
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||||
|
f'><i class="fa fa-times"></i></button></div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry_btns.append(
|
||||||
|
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
|
||||||
|
f' data-confirm data-confirm-title="Add entry?"'
|
||||||
|
f' data-confirm-text="Add {e_name} to this post?"'
|
||||||
|
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
|
||||||
|
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||||
|
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||||
|
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||||
|
f""" sx-headers='{hx_hdrs}'"""
|
||||||
|
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||||
|
f'><span class="truncate block">{e_name}</span></button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
|
||||||
|
cells.append(
|
||||||
|
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
|
||||||
|
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
|
||||||
|
|
||||||
|
html = (
|
||||||
|
f'<div id="calendar-view-{cal_id}"'
|
||||||
|
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
|
||||||
|
f'{nav}'
|
||||||
|
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
return _raw_html_sx(html)
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||||
|
"""Render the associated entries panel."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for as qurl
|
||||||
|
|
||||||
|
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_sx = await render_to_sx("blog-entry-image", src=cal_fi, title=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(await render_to_sx("blog-associated-entry",
|
||||||
|
confirm_text=f"This will remove {e_name} from this post",
|
||||||
|
toggle_url=toggle_url,
|
||||||
|
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||||
|
img=SxExpr(img_sx), name=e_name,
|
||||||
|
date_str=f"{cal_name} \u2022 {date_str}",
|
||||||
|
))
|
||||||
|
|
||||||
|
if has_entries:
|
||||||
|
content_sx = await render_to_sx("blog-associated-entries-content",
|
||||||
|
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content_sx = await render_to_sx("blog-associated-entries-empty")
|
||||||
|
|
||||||
|
return await render_to_sx("blog-associated-entries-panel", content=SxExpr(content_sx))
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||||
|
"""Render the OOB nav entries swap."""
|
||||||
|
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 await render_to_sx("blog-nav-entries-empty")
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
item_parts.append(await render_to_sx("calendar-entry-nav",
|
||||||
|
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
|
||||||
|
))
|
||||||
|
|
||||||
|
for calendar in (calendars or []):
|
||||||
|
cal_name = getattr(calendar, "name", "")
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
cal_path = f"/{post_slug}/{cal_slug}/"
|
||||||
|
|
||||||
|
item_parts.append(await render_to_sx("blog-nav-calendar-item",
|
||||||
|
href=cal_path, nav_cls=nav_cls, name=cal_name,
|
||||||
|
))
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||||
|
|
||||||
|
return await render_to_sx("scroll-nav-wrapper",
|
||||||
|
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
|
||||||
|
arrow_cls="entries-nav-arrow",
|
||||||
|
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
|
||||||
|
scroll_hs=scroll_hs,
|
||||||
|
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
|
||||||
|
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@@ -88,14 +353,7 @@ def register():
|
|||||||
})
|
})
|
||||||
|
|
||||||
features = result.get("features", {})
|
features = result.get("features", {})
|
||||||
|
html = await _render_features(features, post, result)
|
||||||
from sx.sx_components import render_features_panel
|
|
||||||
html = await render_features_panel(
|
|
||||||
features, post,
|
|
||||||
sumup_configured=result.get("sumup_configured", False),
|
|
||||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
|
||||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
|
||||||
)
|
|
||||||
return sx_response(html)
|
return sx_response(html)
|
||||||
|
|
||||||
@bp.put("/admin/sumup/")
|
@bp.put("/admin/sumup/")
|
||||||
@@ -128,13 +386,7 @@ def register():
|
|||||||
result = await call_action("blog", "update-page-config", payload=payload)
|
result = await call_action("blog", "update-page-config", payload=payload)
|
||||||
|
|
||||||
features = result.get("features", {})
|
features = result.get("features", {})
|
||||||
from sx.sx_components import render_features_panel
|
html = await _render_features(features, post, result)
|
||||||
html = await render_features_panel(
|
|
||||||
features, post,
|
|
||||||
sumup_configured=result.get("sumup_configured", False),
|
|
||||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
|
||||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
|
||||||
)
|
|
||||||
return sx_response(html)
|
return sx_response(html)
|
||||||
|
|
||||||
@bp.get("/entries/calendar/<int:calendar_id>/")
|
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||||
@@ -203,8 +455,7 @@ def register():
|
|||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||||
|
|
||||||
from sx.sx_components import render_calendar_view
|
html = _render_calendar_view(
|
||||||
html = render_calendar_view(
|
|
||||||
calendar_obj, year, month, month_name, weekday_names, weeks,
|
calendar_obj, year, month, month_name, weekday_names, weeks,
|
||||||
prev_month, prev_month_year, next_month, next_month_year,
|
prev_month, prev_month_year, next_month, next_month_year,
|
||||||
prev_year, next_year, month_entries, associated_entry_ids,
|
prev_year, next_year, month_entries, associated_entry_ids,
|
||||||
@@ -256,11 +507,9 @@ def register():
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
# Return the associated entries admin list + OOB update for nav entries
|
# Return the associated entries admin list + OOB update for nav entries
|
||||||
from sx.sx_components import render_associated_entries, render_nav_entries_oob
|
|
||||||
|
|
||||||
post = g.post_data["post"]
|
post = g.post_data["post"]
|
||||||
admin_list = await render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
admin_list = await _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||||
nav_entries_html = await render_nav_entries_oob(associated_entries, calendars, post)
|
nav_entries_html = await _render_nav_entries_oob(associated_entries, calendars, post)
|
||||||
|
|
||||||
return sx_response(admin_list + nav_entries_html)
|
return sx_response(admin_list + nav_entries_html)
|
||||||
|
|
||||||
@@ -435,8 +684,11 @@ def register():
|
|||||||
|
|
||||||
page_markets = await _fetch_page_markets(post_id)
|
page_markets = await _fetch_page_markets(post_id)
|
||||||
|
|
||||||
from sx.sx_components import render_markets_panel
|
slug = post.get("slug", "")
|
||||||
return sx_response(await render_markets_panel(page_markets, post))
|
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||||
|
html = await render_to_sx("blog-markets-panel-content",
|
||||||
|
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||||
|
return sx_response(html)
|
||||||
|
|
||||||
@bp.post("/markets/new/")
|
@bp.post("/markets/new/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -461,8 +713,11 @@ def register():
|
|||||||
# Return updated markets list
|
# Return updated markets list
|
||||||
page_markets = await _fetch_page_markets(post_id)
|
page_markets = await _fetch_page_markets(post_id)
|
||||||
|
|
||||||
from sx.sx_components import render_markets_panel
|
slug = post.get("slug", "")
|
||||||
return sx_response(await render_markets_panel(page_markets, post))
|
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||||
|
html = await render_to_sx("blog-markets-panel-content",
|
||||||
|
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||||
|
return sx_response(html)
|
||||||
|
|
||||||
@bp.delete("/markets/<market_slug>/")
|
@bp.delete("/markets/<market_slug>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -481,7 +736,10 @@ def register():
|
|||||||
# Return updated markets list
|
# Return updated markets list
|
||||||
page_markets = await _fetch_page_markets(post_id)
|
page_markets = await _fetch_page_markets(post_id)
|
||||||
|
|
||||||
from sx.sx_components import render_markets_panel
|
slug = post.get("slug", "")
|
||||||
return sx_response(await render_markets_panel(page_markets, post))
|
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||||
|
html = await render_to_sx("blog-markets-panel-content",
|
||||||
|
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||||
|
return sx_response(html)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -105,27 +105,68 @@ def register():
|
|||||||
@cache_page(tag="post.post_detail")
|
@cache_page(tag="post.post_detail")
|
||||||
async def post_detail(slug: str):
|
async def post_detail(slug: str):
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_post_page, render_post_oob
|
from shared.sx.helpers import (
|
||||||
|
render_to_sx, root_header_sx, full_page_sx, oob_page_sx,
|
||||||
|
post_header_sx, oob_header_sx, mobile_menu_sx,
|
||||||
|
post_mobile_nav_sx, mobile_root_nav_sx,
|
||||||
|
)
|
||||||
|
from shared.services.registry import services
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from shared.utils import host_url
|
||||||
|
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
|
|
||||||
|
# Render post content via .sx defcomp
|
||||||
|
post = tctx.get("post") or {}
|
||||||
|
user = getattr(g, "user", None)
|
||||||
|
rights = tctx.get("rights") or {}
|
||||||
|
blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/")
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
svc = services.get("blog_page")
|
||||||
|
detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base)
|
||||||
|
content = await render_to_sx("blog-post-detail-content", **detail_data)
|
||||||
|
meta_data = svc.post_meta_data(post, tctx.get("base_title", ""))
|
||||||
|
meta = await render_to_sx("blog-meta", **meta_data)
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_post_page(tctx)
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
post_hdr = await post_header_sx(tctx)
|
||||||
|
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||||
|
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
|
||||||
|
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
|
||||||
|
meta=meta, menu=menu)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
else:
|
else:
|
||||||
sx_src = await render_post_oob(tctx)
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
post_hdr = await post_header_sx(tctx)
|
||||||
|
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||||
|
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
|
||||||
|
sx_src = await oob_page_sx(oobs=header_oob, content=content, menu=
|
||||||
|
mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)))
|
||||||
return sx_response(sx_src)
|
return sx_response(sx_src)
|
||||||
|
|
||||||
@bp.post("/like/toggle/")
|
@bp.post("/like/toggle/")
|
||||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||||
async def like_toggle(slug: str):
|
async def like_toggle(slug: str):
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
from sx.sx_components import render_like_toggle_button
|
from shared.sx.helpers import render_to_sx
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
|
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
async def _like_btn(liked):
|
||||||
|
if liked:
|
||||||
|
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post"
|
||||||
|
else:
|
||||||
|
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post"
|
||||||
|
return await render_to_sx("market-like-toggle-button",
|
||||||
|
colour=colour, action=like_url,
|
||||||
|
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||||
|
label=label, icon_cls=icon)
|
||||||
|
|
||||||
# Get post_id from g.post_data
|
|
||||||
if not g.user:
|
if not g.user:
|
||||||
return sx_response(await render_like_toggle_button(slug, False, like_url), status=403)
|
return sx_response(await _like_btn(False), status=403)
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
user_id = g.user.id
|
user_id = g.user.id
|
||||||
@@ -133,9 +174,8 @@ def register():
|
|||||||
result = await call_action("likes", "toggle", payload={
|
result = await call_action("likes", "toggle", payload={
|
||||||
"user_id": user_id, "target_type": "post", "target_id": post_id,
|
"user_id": user_id, "target_type": "post", "target_id": post_id,
|
||||||
})
|
})
|
||||||
liked = result["liked"]
|
|
||||||
|
|
||||||
return sx_response(await render_like_toggle_button(slug, liked, like_url))
|
return sx_response(await _like_btn(result["liked"]))
|
||||||
|
|
||||||
@bp.get("/w/<widget_domain>/")
|
@bp.get("/w/<widget_domain>/")
|
||||||
async def widget_paginate(slug: str, widget_domain: str):
|
async def widget_paginate(slug: str, widget_domain: str):
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, request, g, abort
|
from quart import Blueprint, request, g, abort
|
||||||
from sqlalchemy import select, or_
|
|
||||||
|
|
||||||
from shared.browser.app.authz import require_login
|
from shared.browser.app.authz import require_login
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
from models import Snippet
|
from models import Snippet
|
||||||
|
|
||||||
|
|
||||||
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
|
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
|
||||||
|
|
||||||
|
|
||||||
async def _visible_snippets(session):
|
async def _render_snippets():
|
||||||
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
|
"""Render snippets list via service data + .sx defcomp."""
|
||||||
uid = g.user.id
|
from shared.services.registry import services
|
||||||
is_admin = g.rights.get("admin")
|
data = await services.get("blog_page").snippets_data(g.s)
|
||||||
|
return await render_to_sx("blog-snippets-content", **data)
|
||||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
|
||||||
if is_admin:
|
|
||||||
filters.append(Snippet.visibility == "admin")
|
|
||||||
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -45,9 +35,7 @@ def register():
|
|||||||
await g.s.delete(snippet)
|
await g.s.delete(snippet)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
snippets = await _visible_snippets(g.s)
|
return sx_response(await _render_snippets())
|
||||||
from sx.sx_components import render_snippets_list
|
|
||||||
return sx_response(await render_snippets_list(snippets, is_admin))
|
|
||||||
|
|
||||||
@bp.patch("/<int:snippet_id>/visibility/")
|
@bp.patch("/<int:snippet_id>/visibility/")
|
||||||
@require_login
|
@require_login
|
||||||
@@ -69,8 +57,6 @@ def register():
|
|||||||
snippet.visibility = visibility
|
snippet.visibility = visibility
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
snippets = await _visible_snippets(g.s)
|
return sx_response(await _render_snippets())
|
||||||
from sx.sx_components import render_snippets_list
|
|
||||||
return sx_response(await render_snippets_list(snippets, True))
|
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -171,6 +171,165 @@ class BlogPageService:
|
|||||||
"csrf": generate_csrf_token(),
|
"csrf": generate_csrf_token(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def index_data(self, session, **kw):
|
||||||
|
"""Blog index page data — posts or pages listing with filters."""
|
||||||
|
from quart import g, request, url_for as qurl
|
||||||
|
from bp.blog.services.posts_data import posts_data
|
||||||
|
from bp.blog.services.pages_data import pages_data
|
||||||
|
from bp.blog.filters.qs import decode
|
||||||
|
from shared.utils import host_url
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
q = decode()
|
||||||
|
content_type = request.args.get("type", "posts")
|
||||||
|
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||||
|
user = getattr(g, "user", None)
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/")
|
||||||
|
|
||||||
|
if content_type == "pages":
|
||||||
|
data = await pages_data(session, q.page, q.search)
|
||||||
|
posts_list = data.get("pages", [])
|
||||||
|
tag_groups_raw = []
|
||||||
|
authors_raw = []
|
||||||
|
draft_count = 0
|
||||||
|
selected_tags = ()
|
||||||
|
selected_authors = ()
|
||||||
|
selected_groups = ()
|
||||||
|
else:
|
||||||
|
show_drafts = bool(q.drafts and user)
|
||||||
|
drafts_user_id = None if (not show_drafts or is_admin) else user.id
|
||||||
|
count_drafts_uid = None if (user and is_admin) else (user.id if user else False)
|
||||||
|
data = await posts_data(
|
||||||
|
session, q.page, q.search, q.sort, q.selected_tags,
|
||||||
|
q.selected_authors, q.liked,
|
||||||
|
drafts=show_drafts, drafts_user_id=drafts_user_id,
|
||||||
|
count_drafts_for_user_id=count_drafts_uid,
|
||||||
|
selected_groups=q.selected_groups,
|
||||||
|
)
|
||||||
|
posts_list = data.get("posts", [])
|
||||||
|
tag_groups_raw = data.get("tag_groups", [])
|
||||||
|
authors_raw = data.get("authors", [])
|
||||||
|
draft_count = data.get("draft_count", 0)
|
||||||
|
selected_tags = q.selected_tags
|
||||||
|
selected_authors = q.selected_authors
|
||||||
|
selected_groups = q.selected_groups
|
||||||
|
|
||||||
|
page_num = data.get("page", q.page)
|
||||||
|
total_pages = data.get("total_pages", 1)
|
||||||
|
card_widgets = data.get("card_widgets_html", {})
|
||||||
|
|
||||||
|
current_local_href = f"{blog_url_base}/index"
|
||||||
|
if content_type == "pages":
|
||||||
|
current_local_href += "?type=pages"
|
||||||
|
hx_select = "#main-panel"
|
||||||
|
|
||||||
|
# Serialize posts for cards
|
||||||
|
def _format_ts(dt):
|
||||||
|
if not dt:
|
||||||
|
return ""
|
||||||
|
return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
for p in posts_list:
|
||||||
|
slug = p.get("slug", "")
|
||||||
|
href = f"{blog_url_base}/{slug}/"
|
||||||
|
status = p.get("status", "published")
|
||||||
|
is_draft = status == "draft"
|
||||||
|
ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at"))
|
||||||
|
tags = []
|
||||||
|
for t in (p.get("tags") or []):
|
||||||
|
name = t.get("name") or getattr(t, "name", "")
|
||||||
|
fi = t.get("feature_image") or getattr(t, "feature_image", None)
|
||||||
|
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
|
||||||
|
authors = []
|
||||||
|
for a in (p.get("authors") or []):
|
||||||
|
name = a.get("name") or getattr(a, "name", "")
|
||||||
|
img = a.get("profile_image") or getattr(a, "profile_image", None)
|
||||||
|
authors.append({"name": name, "image": img or ""})
|
||||||
|
card = {
|
||||||
|
"slug": slug, "href": href, "hx_select": hx_select,
|
||||||
|
"title": p.get("title", ""), "feature_image": p.get("feature_image"),
|
||||||
|
"excerpt": p.get("custom_excerpt") or p.get("excerpt", ""),
|
||||||
|
"is_draft": is_draft,
|
||||||
|
"publish_requested": p.get("publish_requested", False) if is_draft else False,
|
||||||
|
"status_timestamp": ts,
|
||||||
|
"tags": tags, "authors": authors,
|
||||||
|
"has_like": bool(user),
|
||||||
|
}
|
||||||
|
if user:
|
||||||
|
card["liked"] = p.get("is_liked", False)
|
||||||
|
card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/"
|
||||||
|
card["csrf_token"] = csrf
|
||||||
|
widget = card_widgets.get(str(p.get("id", "")), "")
|
||||||
|
if widget:
|
||||||
|
card["widget"] = widget
|
||||||
|
# Page-specific fields
|
||||||
|
features = p.get("features") or {}
|
||||||
|
if content_type == "pages":
|
||||||
|
card["has_calendar"] = features.get("calendar", False)
|
||||||
|
card["has_market"] = features.get("market", False)
|
||||||
|
card["pub_timestamp"] = ts
|
||||||
|
cards.append(card)
|
||||||
|
|
||||||
|
# Serialize tag groups for filter
|
||||||
|
tag_groups = []
|
||||||
|
for grp in tag_groups_raw:
|
||||||
|
g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "")
|
||||||
|
g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "")
|
||||||
|
g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None)
|
||||||
|
g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None)
|
||||||
|
g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0)
|
||||||
|
if g_count <= 0 and g_slug not in selected_groups:
|
||||||
|
continue
|
||||||
|
tag_groups.append({
|
||||||
|
"slug": g_slug, "name": g_name, "feature_image": g_fi,
|
||||||
|
"colour": g_colour, "post_count": g_count,
|
||||||
|
"is_selected": g_slug in selected_groups,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Serialize authors for filter
|
||||||
|
authors_list = []
|
||||||
|
for a in authors_raw:
|
||||||
|
a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "")
|
||||||
|
a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "")
|
||||||
|
a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None)
|
||||||
|
a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0)
|
||||||
|
authors_list.append({
|
||||||
|
"slug": a_slug, "name": a_name, "profile_image": a_img,
|
||||||
|
"published_post_count": a_count,
|
||||||
|
"is_selected": a_slug in selected_authors,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Filter summary names
|
||||||
|
tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]]
|
||||||
|
au_summary_names = [a["name"] for a in authors_list if a["is_selected"]]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content_type": content_type,
|
||||||
|
"view": q.view,
|
||||||
|
"cards": cards,
|
||||||
|
"page": page_num,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"current_local_href": current_local_href,
|
||||||
|
"hx_select": hx_select,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"has_user": bool(user),
|
||||||
|
"draft_count": draft_count,
|
||||||
|
"drafts": bool(q.drafts) if user else False,
|
||||||
|
"new_post_href": f"{blog_url_base}/new/",
|
||||||
|
"new_page_href": f"{blog_url_base}/new-page/",
|
||||||
|
"tag_groups": tag_groups,
|
||||||
|
"authors": authors_list,
|
||||||
|
"is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0,
|
||||||
|
"is_any_author": len(selected_authors) == 0,
|
||||||
|
"tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "",
|
||||||
|
"au_summary": ", ".join(au_summary_names) if au_summary_names else "",
|
||||||
|
"blog_url_base": blog_url_base,
|
||||||
|
"csrf": csrf,
|
||||||
|
}
|
||||||
|
|
||||||
async def post_admin_data(self, session, *, slug=None, **kw):
|
async def post_admin_data(self, session, *, slug=None, **kw):
|
||||||
"""Post admin panel — just needs post loaded into context."""
|
"""Post admin panel — just needs post loaded into context."""
|
||||||
from quart import g
|
from quart import g
|
||||||
@@ -196,6 +355,78 @@ class BlogPageService:
|
|||||||
"sumup_configured": sumup_configured,
|
"sumup_configured": sumup_configured,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def post_meta_data(self, post, base_title):
|
||||||
|
"""Compute SEO meta tag values from post dict."""
|
||||||
|
import re
|
||||||
|
from quart import request as req
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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"):
|
||||||
|
desc = re.sub(r'<[^>]+>', '', post["html"])
|
||||||
|
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
|
||||||
|
|
||||||
|
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
|
||||||
|
canonical = post.get("canonical_url") or (req.url if req else "")
|
||||||
|
|
||||||
|
post_title = post.get("meta_title") or post.get("title") or ""
|
||||||
|
page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
|
||||||
|
og_title = post.get("og_title") or page_title
|
||||||
|
tw_title = post.get("twitter_title") or page_title
|
||||||
|
is_article = not post.get("is_page")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"robots": robots, "page_title": page_title, "desc": desc,
|
||||||
|
"canonical": canonical,
|
||||||
|
"og_type": "article" if is_article else "website",
|
||||||
|
"og_title": og_title, "image": image,
|
||||||
|
"twitter_card": "summary_large_image" if image else "summary",
|
||||||
|
"twitter_title": tw_title,
|
||||||
|
}
|
||||||
|
|
||||||
|
def post_detail_data(self, post, user, rights, csrf, blog_url_base):
|
||||||
|
"""Serialize post detail view data for ~blog-post-detail-content defcomp."""
|
||||||
|
slug = post.get("slug", "")
|
||||||
|
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||||
|
user_id = getattr(user, "id", None) if user else None
|
||||||
|
|
||||||
|
# Tags and authors
|
||||||
|
tags = []
|
||||||
|
for t in (post.get("tags") or []):
|
||||||
|
name = t.get("name") or getattr(t, "name", "")
|
||||||
|
fi = t.get("feature_image") or getattr(t, "feature_image", None)
|
||||||
|
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
|
||||||
|
authors = []
|
||||||
|
for a in (post.get("authors") or []):
|
||||||
|
name = a.get("name") or getattr(a, "name", "")
|
||||||
|
img = a.get("profile_image") or getattr(a, "profile_image", None)
|
||||||
|
authors.append({"name": name, "image": img or ""})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"slug": slug,
|
||||||
|
"is_draft": post.get("status") == "draft",
|
||||||
|
"publish_requested": post.get("publish_requested", False),
|
||||||
|
"can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id),
|
||||||
|
"edit_href": f"{blog_url_base}/{slug}/admin/edit/",
|
||||||
|
"is_page": bool(post.get("is_page")),
|
||||||
|
"has_user": bool(user),
|
||||||
|
"liked": post.get("is_liked", False),
|
||||||
|
"like_url": f"{blog_url_base}/{slug}/like/toggle/",
|
||||||
|
"csrf": csrf,
|
||||||
|
"custom_excerpt": post.get("custom_excerpt") or "",
|
||||||
|
"tags": tags,
|
||||||
|
"authors": authors,
|
||||||
|
"feature_image": post.get("feature_image"),
|
||||||
|
"html_content": post.get("html", ""),
|
||||||
|
"sx_content": post.get("sx_content", ""),
|
||||||
|
}
|
||||||
|
|
||||||
async def preview_data(self, session, *, slug=None, **kw):
|
async def preview_data(self, session, *, slug=None, **kw):
|
||||||
"""Build preview data with prettified/rendered content."""
|
"""Build preview data with prettified/rendered content."""
|
||||||
from quart import g
|
from quart import g
|
||||||
|
|||||||
@@ -36,6 +36,37 @@
|
|||||||
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
|
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||||
(div :class "pb-8")))
|
(div :class "pb-8")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Data-driven composition — replaces _post_main_panel_sx
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~blog-post-detail-content (&key slug is-draft publish-requested can-edit edit-href
|
||||||
|
is-page has-user liked like-url csrf
|
||||||
|
custom-excerpt tags authors
|
||||||
|
feature-image html-content sx-content)
|
||||||
|
(let* ((hx-select "#main-panel")
|
||||||
|
(draft-sx (when is-draft
|
||||||
|
(~blog-detail-draft
|
||||||
|
:publish-requested publish-requested
|
||||||
|
:edit (when can-edit
|
||||||
|
(~blog-detail-edit-link :href edit-href :hx-select hx-select)))))
|
||||||
|
(chrome-sx (when (not is-page)
|
||||||
|
(~blog-detail-chrome
|
||||||
|
:like (when has-user
|
||||||
|
(~blog-detail-like
|
||||||
|
:like-url like-url
|
||||||
|
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||||
|
:heart (if liked "\u2764\ufe0f" "\U0001f90d")))
|
||||||
|
:excerpt (when (not (= custom-excerpt ""))
|
||||||
|
(~blog-detail-excerpt :excerpt custom-excerpt))
|
||||||
|
:at-bar (~blog-at-bar :tags tags :authors authors)))))
|
||||||
|
(~blog-detail-main
|
||||||
|
:draft draft-sx
|
||||||
|
:chrome chrome-sx
|
||||||
|
:feature-image feature-image
|
||||||
|
:html-content html-content
|
||||||
|
:sx-content sx-content)))
|
||||||
|
|
||||||
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
||||||
(<>
|
(<>
|
||||||
(meta :name "robots" :content robots)
|
(meta :name "robots" :content robots)
|
||||||
|
|||||||
221
blog/sx/index.sx
221
blog/sx/index.sx
@@ -30,3 +30,224 @@
|
|||||||
tag-groups-filter
|
tag-groups-filter
|
||||||
authors-filter)
|
authors-filter)
|
||||||
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
|
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Data-driven composition defcomps — replace Python sx_components functions
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
;; Helper: CSS class for filter item based on selection state
|
||||||
|
(defcomp ~blog-filter-cls (&key is-on)
|
||||||
|
;; Returns nothing — use inline (if is-on ...) instead
|
||||||
|
nil)
|
||||||
|
|
||||||
|
;; Blog index main content — replaces _blog_main_panel_sx
|
||||||
|
(defcomp ~blog-index-main-content (&key content-type view cards page total-pages
|
||||||
|
current-local-href hx-select blog-url-base)
|
||||||
|
(let* ((posts-href (str blog-url-base "/index"))
|
||||||
|
(pages-href (str posts-href "?type=pages"))
|
||||||
|
(posts-cls (if (not (= content-type "pages"))
|
||||||
|
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))
|
||||||
|
(pages-cls (if (= content-type "pages")
|
||||||
|
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
|
||||||
|
(if (= content-type "pages")
|
||||||
|
;; Pages listing
|
||||||
|
(~blog-main-panel-pages
|
||||||
|
:tabs (~blog-content-type-tabs
|
||||||
|
:posts-href posts-href :pages-href pages-href
|
||||||
|
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||||
|
:cards (<>
|
||||||
|
(map (lambda (card)
|
||||||
|
(~blog-page-card
|
||||||
|
:href (get card "href") :hx-select hx-select
|
||||||
|
:title (get card "title")
|
||||||
|
:has-calendar (get card "has_calendar")
|
||||||
|
:has-market (get card "has_market")
|
||||||
|
:pub-timestamp (get card "pub_timestamp")
|
||||||
|
:feature-image (get card "feature_image")
|
||||||
|
:excerpt (get card "excerpt")))
|
||||||
|
(or cards (list)))
|
||||||
|
(if (< page total-pages)
|
||||||
|
(~sentinel-simple
|
||||||
|
:id (str "sentinel-" page "-d")
|
||||||
|
:next-url (str current-local-href
|
||||||
|
(if (contains? current-local-href "?") "&" "?")
|
||||||
|
"page=" (+ page 1)))
|
||||||
|
(if (not (empty? (or cards (list))))
|
||||||
|
(~end-of-results)
|
||||||
|
(~blog-no-pages)))))
|
||||||
|
;; Posts listing
|
||||||
|
(let* ((grid-cls (if (= view "tile")
|
||||||
|
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||||
|
"max-w-full px-3 py-3 space-y-3"))
|
||||||
|
(list-href current-local-href)
|
||||||
|
(tile-href (str current-local-href
|
||||||
|
(if (contains? current-local-href "?") "&" "?") "view=tile"))
|
||||||
|
(list-cls (if (not (= view "tile"))
|
||||||
|
"bg-stone-200 text-stone-800"
|
||||||
|
"text-stone-400 hover:text-stone-600"))
|
||||||
|
(tile-cls (if (= view "tile")
|
||||||
|
"bg-stone-200 text-stone-800"
|
||||||
|
"text-stone-400 hover:text-stone-600")))
|
||||||
|
(~blog-main-panel-posts
|
||||||
|
:tabs (~blog-content-type-tabs
|
||||||
|
:posts-href posts-href :pages-href pages-href
|
||||||
|
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||||
|
:toggle (~view-toggle
|
||||||
|
:list-href list-href :tile-href tile-href :hx-select hx-select
|
||||||
|
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
|
||||||
|
:list-svg (~list-svg) :tile-svg (~tile-svg))
|
||||||
|
:grid-cls grid-cls
|
||||||
|
:cards (<>
|
||||||
|
(map (lambda (card)
|
||||||
|
(if (= view "tile")
|
||||||
|
(~blog-card-tile
|
||||||
|
:href (get card "href") :hx-select hx-select
|
||||||
|
:feature-image (get card "feature_image")
|
||||||
|
:title (get card "title") :is-draft (get card "is_draft")
|
||||||
|
:publish-requested (get card "publish_requested")
|
||||||
|
:status-timestamp (get card "status_timestamp")
|
||||||
|
:excerpt (get card "excerpt")
|
||||||
|
:tags (get card "tags") :authors (get card "authors"))
|
||||||
|
(~blog-card
|
||||||
|
:slug (get card "slug") :href (get card "href") :hx-select hx-select
|
||||||
|
:title (get card "title") :feature-image (get card "feature_image")
|
||||||
|
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
|
||||||
|
:publish-requested (get card "publish_requested")
|
||||||
|
:status-timestamp (get card "status_timestamp")
|
||||||
|
:has-like (get card "has_like") :liked (get card "liked")
|
||||||
|
:like-url (get card "like_url") :csrf-token (get card "csrf_token")
|
||||||
|
:tags (get card "tags") :authors (get card "authors")
|
||||||
|
:widget (get card "widget"))))
|
||||||
|
(or cards (list)))
|
||||||
|
(~blog-index-sentinel
|
||||||
|
:page page :total-pages total-pages
|
||||||
|
:current-local-href current-local-href)))))))
|
||||||
|
|
||||||
|
;; Sentinel for blog index infinite scroll
|
||||||
|
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href)
|
||||||
|
(when (< page total-pages)
|
||||||
|
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
|
||||||
|
(~sentinel-desktop
|
||||||
|
:id (str "sentinel-" page "-d")
|
||||||
|
:next-url next-url
|
||||||
|
:hyperscript "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()"))))
|
||||||
|
|
||||||
|
;; Blog index action buttons — replaces _action_buttons_sx
|
||||||
|
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts
|
||||||
|
new-post-href new-page-href current-local-href)
|
||||||
|
(~blog-action-buttons-wrapper
|
||||||
|
:inner (<>
|
||||||
|
(when is-admin
|
||||||
|
(<>
|
||||||
|
(~blog-action-button
|
||||||
|
:href new-post-href :hx-select hx-select
|
||||||
|
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||||
|
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
|
||||||
|
(~blog-action-button
|
||||||
|
:href new-page-href :hx-select hx-select
|
||||||
|
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
|
||||||
|
(when (and has-user (or draft-count drafts))
|
||||||
|
(if drafts
|
||||||
|
(~blog-drafts-button
|
||||||
|
:href current-local-href :hx-select hx-select
|
||||||
|
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||||
|
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
|
||||||
|
(let* ((on-href (str current-local-href
|
||||||
|
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
|
||||||
|
(~blog-drafts-button-amber
|
||||||
|
:href on-href :hx-select hx-select
|
||||||
|
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||||
|
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
|
||||||
|
|
||||||
|
;; Tag groups filter — replaces _tag_groups_filter_sx
|
||||||
|
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select)
|
||||||
|
(~blog-filter-nav
|
||||||
|
:items (<>
|
||||||
|
(~blog-filter-any-topic
|
||||||
|
:cls (if is-any-group
|
||||||
|
"bg-stone-900 text-white border-stone-900"
|
||||||
|
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||||
|
:hx-select hx-select)
|
||||||
|
(map (lambda (grp)
|
||||||
|
(let* ((is-on (get grp "is_selected"))
|
||||||
|
(cls (if is-on
|
||||||
|
"bg-stone-900 text-white border-stone-900"
|
||||||
|
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||||
|
(fi (get grp "feature_image"))
|
||||||
|
(colour (get grp "colour"))
|
||||||
|
(name (get grp "name"))
|
||||||
|
(icon (if fi
|
||||||
|
(~blog-filter-group-icon-image :src fi :name name)
|
||||||
|
(~blog-filter-group-icon-color
|
||||||
|
:style (if colour
|
||||||
|
(str "background-color: " colour "; color: white;")
|
||||||
|
"background-color: #e7e5e4; color: #57534e;")
|
||||||
|
:initial (slice (or name "?") 0 1)))))
|
||||||
|
(~blog-filter-group-li
|
||||||
|
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
|
||||||
|
:hx-select hx-select :icon icon
|
||||||
|
:name name :count (str (get grp "post_count")))))
|
||||||
|
(or tag-groups (list))))))
|
||||||
|
|
||||||
|
;; Authors filter — replaces _authors_filter_sx
|
||||||
|
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select)
|
||||||
|
(~blog-filter-nav
|
||||||
|
:items (<>
|
||||||
|
(~blog-filter-any-author
|
||||||
|
:cls (if is-any-author
|
||||||
|
"bg-stone-900 text-white border-stone-900"
|
||||||
|
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||||
|
:hx-select hx-select)
|
||||||
|
(map (lambda (a)
|
||||||
|
(let* ((is-on (get a "is_selected"))
|
||||||
|
(cls (if is-on
|
||||||
|
"bg-stone-900 text-white border-stone-900"
|
||||||
|
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||||
|
(img (get a "profile_image")))
|
||||||
|
(~blog-filter-author-li
|
||||||
|
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
|
||||||
|
:hx-select hx-select
|
||||||
|
:icon (when img (~blog-filter-author-icon :src img :name (get a "name")))
|
||||||
|
:name (get a "name")
|
||||||
|
:count (str (get a "published_post_count")))))
|
||||||
|
(or authors (list))))))
|
||||||
|
|
||||||
|
;; Blog index aside — replaces _blog_aside_sx
|
||||||
|
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts
|
||||||
|
new-post-href new-page-href current-local-href
|
||||||
|
tag-groups authors is-any-group is-any-author)
|
||||||
|
(~blog-aside
|
||||||
|
:search (~search-desktop)
|
||||||
|
:action-buttons (~blog-index-actions
|
||||||
|
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||||
|
:draft-count draft-count :drafts drafts
|
||||||
|
:new-post-href new-post-href :new-page-href new-page-href
|
||||||
|
:current-local-href current-local-href)
|
||||||
|
:tag-groups-filter (~blog-index-tag-groups-filter
|
||||||
|
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||||
|
:authors-filter (~blog-index-authors-filter
|
||||||
|
:authors authors :is-any-author is-any-author :hx-select hx-select)))
|
||||||
|
|
||||||
|
;; Blog index mobile filter — replaces _blog_filter_sx
|
||||||
|
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts
|
||||||
|
new-post-href new-page-href current-local-href
|
||||||
|
tag-groups authors is-any-group is-any-author
|
||||||
|
tg-summary au-summary)
|
||||||
|
(~mobile-filter
|
||||||
|
:filter-summary (<>
|
||||||
|
(~search-mobile)
|
||||||
|
(when (not (= tg-summary ""))
|
||||||
|
(~blog-filter-summary :text tg-summary))
|
||||||
|
(when (not (= au-summary ""))
|
||||||
|
(~blog-filter-summary :text au-summary)))
|
||||||
|
:action-buttons (~blog-index-actions
|
||||||
|
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||||
|
:draft-count draft-count :drafts drafts
|
||||||
|
:new-post-href new-post-href :new-page-href new-page-href
|
||||||
|
:current-local-href current-local-href)
|
||||||
|
:filter-details (<>
|
||||||
|
(~blog-index-tag-groups-filter
|
||||||
|
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||||
|
(~blog-index-authors-filter
|
||||||
|
:authors authors :is-any-author is-any-author :hx-select hx-select))))
|
||||||
|
|||||||
@@ -54,6 +54,43 @@
|
|||||||
(button :type "submit"
|
(button :type "submit"
|
||||||
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
|
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Data-driven composition defcomps — replace Python render_* functions
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
;; Features panel composition — replaces render_features_panel
|
||||||
|
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
|
||||||
|
show-sumup sumup-url merchant-code placeholder
|
||||||
|
sumup-configured checkout-prefix)
|
||||||
|
(~blog-features-panel
|
||||||
|
:form (~blog-features-form
|
||||||
|
:features-url features-url
|
||||||
|
:calendar-checked calendar-checked
|
||||||
|
:market-checked market-checked
|
||||||
|
:hs-trigger "on change trigger submit on closest <form/>")
|
||||||
|
:sumup (when show-sumup
|
||||||
|
(~blog-sumup-form
|
||||||
|
:sumup-url sumup-url
|
||||||
|
:merchant-code merchant-code
|
||||||
|
:placeholder placeholder
|
||||||
|
:sumup-configured sumup-configured
|
||||||
|
:checkout-prefix checkout-prefix))))
|
||||||
|
|
||||||
|
;; Markets panel composition — replaces render_markets_panel
|
||||||
|
(defcomp ~blog-markets-panel-content (&key markets create-url)
|
||||||
|
(~blog-markets-panel
|
||||||
|
:list (if (empty? (or markets (list)))
|
||||||
|
(~blog-markets-empty)
|
||||||
|
(~blog-markets-list
|
||||||
|
:items (map (lambda (m)
|
||||||
|
(~blog-market-item
|
||||||
|
:name (get m "name")
|
||||||
|
:slug (get m "slug")
|
||||||
|
:delete-url (get m "delete_url")
|
||||||
|
:confirm-text (str "Delete market '" (get m "name") "'?")))
|
||||||
|
(or markets (list)))))
|
||||||
|
:create-url create-url))
|
||||||
|
|
||||||
;; Associated entries
|
;; Associated entries
|
||||||
|
|
||||||
(defcomp ~blog-entry-image (&key src title)
|
(defcomp ~blog-entry-image (&key src title)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user