diff --git a/account/sexp/sexp_components.py b/account/sexp/sexp_components.py
index 53a3ae8..846a683 100644
--- a/account/sexp/sexp_components.py
+++ b/account/sexp/sexp_components.py
@@ -69,43 +69,59 @@ def _account_main_panel_html(ctx: dict) -> str:
user = getattr(g, "user", None)
error = ctx.get("error", "")
- parts = ['
',
- '
']
+ error_html = sexp(
+ '(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" (raw! e))',
+ e=error,
+ ) if error else ""
- if error:
- parts.append(
- f'
{error}
'
+ user_email_html = ""
+ user_name_html = ""
+ if user:
+ user_email_html = sexp(
+ '(p :class "text-sm text-stone-500 mt-1" (raw! e))',
+ e=user.email,
+ )
+ if user.name:
+ user_name_html = sexp(
+ '(p :class "text-sm text-stone-600" (raw! n))',
+ n=user.name,
+ )
+
+ logout_html = sexp(
+ '(form :action "/auth/logout/" :method "post"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type "submit"'
+ ' :class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"'
+ ' (i :class "fa-solid fa-right-from-bracket text-xs") " Sign out"))',
+ csrf=generate_csrf_token(),
+ )
+
+ labels_html = ""
+ if user and hasattr(user, "labels") and user.labels:
+ label_items = "".join(
+ sexp(
+ '(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" (raw! n))',
+ n=label.name,
+ )
+ for label in user.labels
+ )
+ labels_html = sexp(
+ '(div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")'
+ ' (div :class "flex flex-wrap gap-2" (raw! items)))',
+ items=label_items,
)
- # Account header with logout
- parts.append('
')
- parts.append('
Account ')
- if user:
- parts.append(f'
{user.email}
')
- if user.name:
- parts.append(f'
{user.name}
')
- parts.append('
')
- parts.append(
- f'
'
+ return sexp(
+ '(div :class "w-full max-w-3xl mx-auto px-4 py-6"'
+ ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"'
+ ' (raw! err)'
+ ' (div :class "flex items-center justify-between"'
+ ' (div (h1 :class "text-xl font-semibold tracking-tight" "Account") (raw! email) (raw! name))'
+ ' (raw! logout))'
+ ' (raw! labels)))',
+ err=error_html, email=user_email_html, name=user_name_html,
+ logout=logout_html, labels=labels_html,
)
- parts.append('
')
-
- # Labels
- if user and hasattr(user, "labels") and user.labels:
- parts.append('
Labels ')
- parts.append('
')
- for label in user.labels:
- parts.append(
- f''
- f'{label.name} '
- )
- parts.append('
')
-
- parts.append('
')
- return "".join(parts)
# ---------------------------------------------------------------------------
@@ -124,16 +140,31 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
- return (
- f''
- f''
- f' '
- f'
'
+ return sexp(
+ '(div :id id :class "flex items-center"'
+ ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"'
+ ' :class cls :role "switch" :aria-checked checked'
+ ' (span :class knob)))',
+ id=f"nl-{nid}", url=toggle_url,
+ hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
+ tgt=f"#nl-{nid}",
+ cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
+ checked=checked,
+ knob=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
+ )
+
+
+def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str:
+ """Render an unsubscribed newsletter toggle (no subscription record yet)."""
+ return sexp(
+ '(div :id id :class "flex items-center"'
+ ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"'
+ ' :class "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"'
+ ' :role "switch" :aria-checked "false"'
+ ' (span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))',
+ id=f"nl-{nid}", url=toggle_url,
+ hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
+ tgt=f"#nl-{nid}",
)
@@ -144,44 +175,45 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
account_url_fn = ctx.get("account_url") or (lambda p: p)
csrf = generate_csrf_token()
- parts = ['',
- '
',
- '
Newsletters ']
-
if newsletter_list:
- parts.append('
')
+ items = []
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
- parts.append('
')
- parts.append(f'
{nl.name}
')
- if nl.description:
- parts.append(f'
{nl.description}
')
- parts.append('
')
+
+ desc_html = sexp(
+ '(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! d))',
+ d=nl.description,
+ ) if nl.description else ""
if un:
- parts.append(_newsletter_toggle_html(un, account_url_fn, csrf))
+ toggle = _newsletter_toggle_html(un, account_url_fn, csrf)
else:
- # No subscription yet — show off toggle
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
- parts.append(
- f'
'
- f''
- f' '
- f'
'
- )
- parts.append('
')
- parts.append('
')
- else:
- parts.append('
No newsletters available.
')
+ toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
- parts.append('
')
- return "".join(parts)
+ items.append(sexp(
+ '(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"'
+ ' (div :class "min-w-0 flex-1"'
+ ' (p :class "text-sm font-medium text-stone-800" (raw! name))'
+ ' (raw! desc))'
+ ' (div :class "ml-4 flex-shrink-0" (raw! toggle)))',
+ name=nl.name, desc=desc_html, toggle=toggle,
+ ))
+ list_html = sexp(
+ '(div :class "divide-y divide-stone-100" (raw! items))',
+ items="".join(items),
+ )
+ else:
+ list_html = sexp('(p :class "text-sm text-stone-500" "No newsletters available.")')
+
+ return sexp(
+ '(div :class "w-full max-w-3xl mx-auto px-4 py-6"'
+ ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"'
+ ' (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")'
+ ' (raw! list)))',
+ list=list_html,
+ )
# ---------------------------------------------------------------------------
@@ -195,25 +227,29 @@ def _login_page_content(ctx: dict) -> str:
error = ctx.get("error", "")
email = ctx.get("email", "")
-
- parts = ['',
- '
Sign in ']
- if error:
- parts.append(
- f'
{error}
'
- )
action = url_for("auth.start_login")
- parts.append(
- f'
'
+
+ error_html = sexp(
+ '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))',
+ e=error,
+ ) if error else ""
+
+ return sexp(
+ '(div :class "py-8 max-w-md mx-auto"'
+ ' (h1 :class "text-2xl font-bold mb-6" "Sign in")'
+ ' (raw! err)'
+ ' (form :method "post" :action action :class "space-y-4"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (div'
+ ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")'
+ ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true'
+ ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))'
+ ' (button :type "submit"'
+ ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"'
+ ' "Send magic link")))',
+ err=error_html, action=action,
+ csrf=generate_csrf_token(), email=email,
)
- parts.append('
')
- return "".join(parts)
def _device_page_content(ctx: dict) -> str:
@@ -223,36 +259,39 @@ def _device_page_content(ctx: dict) -> str:
error = ctx.get("error", "")
code = ctx.get("code", "")
-
- parts = ['',
- '
Authorize device ',
- '
Enter the code shown in your terminal to sign in.
']
- if error:
- parts.append(
- f'
{error}
'
- )
action = url_for("auth.device_submit")
- parts.append(
- f'
'
+
+ error_html = sexp(
+ '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))',
+ e=error,
+ ) if error else ""
+
+ return sexp(
+ '(div :class "py-8 max-w-md mx-auto"'
+ ' (h1 :class "text-2xl font-bold mb-6" "Authorize device")'
+ ' (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")'
+ ' (raw! err)'
+ ' (form :method "post" :action action :class "space-y-4"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (div'
+ ' (label :for "code" :class "block text-sm font-medium mb-1" "Device code")'
+ ' (input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"'
+ ' :required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"'
+ ' :class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))'
+ ' (button :type "submit"'
+ ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"'
+ ' "Authorize")))',
+ err=error_html, action=action,
+ csrf=generate_csrf_token(), code=code,
)
- parts.append('
')
- return "".join(parts)
def _device_approved_content() -> str:
"""Device approved success content."""
- return (
- ''
- '
Device authorized '
- '
You can close this window and return to your terminal.
'
- '
'
+ return sexp(
+ '(div :class "py-8 max-w-md mx-auto text-center"'
+ ' (h1 :class "text-2xl font-bold mb-4" "Device authorized")'
+ ' (p :class "text-stone-600" "You can close this window and return to your terminal."))',
)
@@ -387,18 +426,18 @@ def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content."""
from markupsafe import escape
- error_html = ""
- if email_error:
- error_html = (
- f''
- f'{escape(email_error)}
'
- )
- return (
- ''
- '
Check your email '
- f'
We sent a sign-in link to {escape(email)} .
'
- '
Click the link in the email to sign in. The link expires in 15 minutes.
'
- f'{error_html}
'
+ error_html = sexp(
+ '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))',
+ e=str(escape(email_error)),
+ ) if email_error else ""
+
+ return sexp(
+ '(div :class "py-8 max-w-md mx-auto text-center"'
+ ' (h1 :class "text-2xl font-bold mb-4" "Check your email")'
+ ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")'
+ ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")'
+ ' (raw! err))',
+ email=str(escape(email)), err=error_html,
)
diff --git a/federation/sexp/sexp_components.py b/federation/sexp/sexp_components.py
index 8057ca6..e416cca 100644
--- a/federation/sexp/sexp_components.py
+++ b/federation/sexp/sexp_components.py
@@ -23,10 +23,10 @@ def _social_nav_html(actor: Any) -> str:
if not actor:
choose_url = url_for("identity.choose_username_form")
- return (
- ''
- f'Choose username '
- ' '
+ return sexp(
+ '(nav :class "flex gap-3 text-sm items-center"'
+ ' (a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username"))',
+ url=choose_url,
)
links = [
@@ -38,27 +38,40 @@ def _social_nav_html(actor: Any) -> str:
("social.search", "Search"),
]
- parts = ['']
+ parts = []
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
- parts.append(f'{label} ')
+ parts.append(sexp(
+ '(a :href href :class cls (raw! label))',
+ href=href,
+ cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
+ label=label,
+ ))
# Notifications with live badge
notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
- parts.append(
- f'Notifications'
- f' '
- )
+ parts.append(sexp(
+ '(a :href href :class cls "Notifications"'
+ ' (span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"'
+ ' :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))',
+ href=notif_url, cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
+ **{"count-url": notif_count_url},
+ ))
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
- parts.append(f'@{actor.preferred_username} ')
- parts.append(' ')
- return "".join(parts)
+ parts.append(sexp(
+ '(a :href href :class "px-2 py-1 rounded hover:bg-stone-200" (raw! label))',
+ href=profile_url, label=f"@{actor.preferred_username}",
+ ))
+
+ return sexp(
+ '(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items))',
+ items="".join(parts),
+ )
def _social_header_html(actor: Any) -> str:
@@ -80,7 +93,7 @@ def _social_page(ctx: dict, actor: Any, *, content_html: str,
sh=_social_header_html(actor),
)
return full_page(ctx, header_rows_html=hdr, content_html=content_html,
- meta_html=meta_html or f'{escape(title)} ')
+ meta_html=meta_html or sexp('(title (raw! t))', t=escape(title)))
# ---------------------------------------------------------------------------
@@ -106,11 +119,11 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str:
if liked:
like_action = url_for("social.unlike")
like_cls = "text-red-500 hover:text-red-600"
- like_icon = "♥"
+ like_icon = "\u2665"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
- like_icon = "♡"
+ like_icon = "\u2661"
if boosted:
boost_action = url_for("social.unboost")
@@ -120,21 +133,37 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str:
boost_cls = "hover:text-green-600"
reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
- reply_html = f'Reply ' if reply_url else ""
+ reply_html = sexp(
+ '(a :href url :class "hover:text-stone-700" "Reply")',
+ url=reply_url,
+ ) if reply_url else ""
- return (
- f''
- f''
- f''
- f'{reply_html}
'
+ like_form = sexp(
+ '(form :hx-post action :hx-target target :hx-swap "innerHTML"'
+ ' (input :type "hidden" :name "object_id" :value oid)'
+ ' (input :type "hidden" :name "author_inbox" :value ainbox)'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type "submit" :class cls (span (raw! icon)) " " (raw! count)))',
+ action=like_action, target=target, oid=oid, ainbox=ainbox,
+ csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
+ icon=like_icon, count=str(lcount),
+ )
+
+ boost_form = sexp(
+ '(form :hx-post action :hx-target target :hx-swap "innerHTML"'
+ ' (input :type "hidden" :name "object_id" :value oid)'
+ ' (input :type "hidden" :name "author_inbox" :value ainbox)'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type "submit" :class cls (span "\u21bb") " " (raw! count)))',
+ action=boost_action, target=target, oid=oid, ainbox=ainbox,
+ csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
+ count=str(bcount),
+ )
+
+ return sexp(
+ '(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"'
+ ' (raw! like) (raw! boost) (raw! reply))',
+ like=like_form, boost=boost_form, reply=reply_html,
)
@@ -151,45 +180,74 @@ def _post_card_html(item: Any, actor: Any) -> str:
url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "")
- boost_html = f'Boosted by {escape(boosted_by)}
' if boosted_by else ""
+ boost_html = sexp(
+ '(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))',
+ name=str(escape(boosted_by)),
+ ) if boosted_by else ""
if actor_icon:
- avatar = f' '
+ avatar = sexp(
+ '(img :src src :alt "" :class "w-10 h-10 rounded-full")',
+ src=actor_icon,
+ )
else:
initial = actor_name[0].upper() if actor_name else "?"
- avatar = f'{initial}
'
+ avatar = sexp(
+ '(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm" (raw! i))',
+ i=initial,
+ )
domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
time_html = published.strftime("%b %d, %H:%M") if published else ""
if summary:
- content_html = (
- f'CW: {escape(summary)} '
- f'{content}
'
+ content_html = sexp(
+ '(details :class "mt-2"'
+ ' (summary :class "text-stone-500 cursor-pointer" "CW: " (raw! s))'
+ ' (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c)))',
+ s=str(escape(summary)), c=content,
)
else:
- content_html = f'{content}
'
+ content_html = sexp(
+ '(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c))',
+ c=content,
+ )
original_html = ""
if url and post_type == "remote":
- original_html = f'original '
+ original_html = sexp(
+ '(a :href url :target "_blank" :rel "noopener"'
+ ' :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")',
+ url=url,
+ )
interactions_html = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
- interactions_html = f'{_interaction_buttons_html(item, actor)}
'
+ interactions_html = sexp(
+ '(div :id id (raw! buttons))',
+ id=f"interactions-{safe_id}",
+ buttons=_interaction_buttons_html(item, actor),
+ )
- return (
- f''
- f'{boost_html}'
- f'{avatar}'
- f'
'
- f'
'
- f'{escape(actor_name)} '
- f'@{escape(actor_username)}{domain_html} '
- f'{time_html}
'
- f'{content_html}{original_html}{interactions_html}
'
+ return sexp(
+ '(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"'
+ ' (raw! boost)'
+ ' (div :class "flex items-start gap-3"'
+ ' (raw! avatar)'
+ ' (div :class "flex-1 min-w-0"'
+ ' (div :class "flex items-baseline gap-2"'
+ ' (span :class "font-semibold text-stone-900" (raw! aname))'
+ ' (span :class "text-sm text-stone-500" "@" (raw! ausername) (raw! domain))'
+ ' (span :class "text-sm text-stone-400 ml-auto" (raw! time)))'
+ ' (raw! content) (raw! original) (raw! interactions))))',
+ boost=boost_html, avatar=avatar,
+ aname=str(escape(actor_name)),
+ ausername=str(escape(actor_username)),
+ domain=domain_html, time=time_html,
+ content=content_html, original=original_html,
+ interactions=interactions_html,
)
@@ -211,7 +269,10 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any,
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
- parts.append(f'
')
+ parts.append(sexp(
+ '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
+ url=next_url,
+ ))
return "".join(parts)
@@ -238,48 +299,75 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
safe_id = actor_url.replace("/", "_").replace(":", "_")
if icon_url:
- avatar = f' '
+ avatar = sexp(
+ '(img :src src :alt "" :class "w-12 h-12 rounded-full")',
+ src=icon_url,
+ )
else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
- avatar = f'{initial}
'
+ avatar = sexp(
+ '(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold" (raw! i))',
+ i=initial,
+ )
# Name link
- if list_type == "following" and aid:
- name_html = f'{escape(display_name)} '
- elif list_type == "search" and aid:
- name_html = f'{escape(display_name)} '
+ if (list_type in ("following", "search")) and aid:
+ name_html = sexp(
+ '(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name))',
+ href=url_for("social.actor_timeline", id=aid),
+ name=str(escape(display_name)),
+ )
else:
- name_html = f'{escape(display_name)} '
+ name_html = sexp(
+ '(a :href href :target "_blank" :rel "noopener"'
+ ' :class "font-semibold text-stone-900 hover:underline" (raw! name))',
+ href=f"https://{domain}/@{username}",
+ name=str(escape(display_name)),
+ )
- summary_html = f'{summary}
' if summary else ""
+ summary_html = sexp(
+ '(div :class "text-sm text-stone-600 mt-1 truncate" (raw! s))',
+ s=summary,
+ ) if summary else ""
# Follow/unfollow button
button_html = ""
if actor:
is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed:
- button_html = (
- f'
'
+ button_html = sexp(
+ '(div :class "flex-shrink-0"'
+ ' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "actor_url" :value aurl)'
+ ' (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))',
+ action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url,
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
- button_html = (
- f'
'
+ button_html = sexp(
+ '(div :class "flex-shrink-0"'
+ ' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "actor_url" :value aurl)'
+ ' (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label))))',
+ action=url_for("social.follow"), csrf=csrf, aurl=actor_url, label=label,
)
- return (
- f''
- f'{avatar}{name_html}'
- f'
@{escape(username)}@{escape(domain)}
'
- f'{summary_html}
{button_html} '
+ return sexp(
+ '(article :class cls :id id'
+ ' (raw! avatar)'
+ ' (div :class "flex-1 min-w-0"'
+ ' (raw! name-link)'
+ ' (div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))'
+ ' (raw! summary))'
+ ' (raw! button))',
+ cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
+ id=f"actor-{safe_id}",
+ avatar=avatar,
+ **{"name-link": name_html},
+ username=str(escape(username)), domain=str(escape(domain)),
+ summary=summary_html, button=button_html,
)
@@ -291,7 +379,10 @@ def _search_results_html(actors: list, query: str, page: int,
parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
- parts.append(f'
')
+ parts.append(sexp(
+ '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
+ url=next_url,
+ ))
return "".join(parts)
@@ -303,7 +394,10 @@ def _actor_list_items_html(actors: list, page: int, list_type: str,
parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20:
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
- parts.append(f'
')
+ parts.append(sexp(
+ '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")',
+ url=next_url,
+ ))
return "".join(parts)
@@ -326,10 +420,16 @@ def _notification_html(notif: Any) -> str:
border = " border-l-4 border-l-stone-400" if not read else ""
if from_icon:
- avatar = f' '
+ avatar = sexp(
+ '(img :src src :alt "" :class "w-8 h-8 rounded-full")',
+ src=from_icon,
+ )
else:
initial = from_name[0].upper() if from_name else "?"
- avatar = f'{initial}
'
+ avatar = sexp(
+ '(div :class "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs" (raw! i))',
+ i=initial,
+ )
domain_html = f"@{escape(from_domain)}" if from_domain else ""
@@ -344,16 +444,29 @@ def _notification_html(notif: Any) -> str:
if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}"
- preview_html = f'{escape(preview)}
' if preview else ""
+ preview_html = sexp(
+ '(div :class "text-sm text-stone-500 mt-1 truncate" (raw! p))',
+ p=str(escape(preview)),
+ ) if preview else ""
time_html = created.strftime("%b %d, %H:%M") if created else ""
- return (
- f''
- f'
{avatar}
'
- f'
{escape(from_name)} '
- f' @{escape(from_username)}{domain_html} '
- f' {action}
'
- f'{preview_html}
{time_html}
'
+ return sexp(
+ '(div :class cls'
+ ' (div :class "flex items-start gap-3"'
+ ' (raw! avatar)'
+ ' (div :class "flex-1"'
+ ' (div :class "text-sm"'
+ ' (span :class "font-semibold" (raw! fname))'
+ ' " " (span :class "text-stone-500" "@" (raw! fusername) (raw! fdomain))'
+ ' " " (span :class "text-stone-600" (raw! action)))'
+ ' (raw! preview)'
+ ' (div :class "text-xs text-stone-400 mt-1" (raw! time)))))',
+ cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
+ avatar=avatar,
+ fname=str(escape(from_name)),
+ fusername=str(escape(from_username)),
+ fdomain=domain_html, action=action,
+ preview=preview_html, time=time_html,
)
@@ -381,21 +494,31 @@ async def render_login_page(ctx: dict) -> str:
action = url_for("auth.start_login")
csrf = generate_csrf_token()
- error_html = f'{error}
' if error else ""
- content = (
- f'Sign in {error_html}'
- f'
'
+ error_html = sexp(
+ '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))',
+ e=error,
+ ) if error else ""
+
+ content = sexp(
+ '(div :class "py-8 max-w-md mx-auto"'
+ ' (h1 :class "text-2xl font-bold mb-6" "Sign in")'
+ ' (raw! err)'
+ ' (form :method "post" :action action :class "space-y-4"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (div'
+ ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")'
+ ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true'
+ ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))'
+ ' (button :type "submit"'
+ ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"'
+ ' "Send magic link")))',
+ err=error_html, action=action, csrf=csrf,
+ email=str(escape(email)),
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
- meta_html="Login \u2014 Rose Ash ")
+ meta_html='Login \u2014 Rose Ash ')
async def render_check_email_page(ctx: dict) -> str:
@@ -403,18 +526,18 @@ async def render_check_email_page(ctx: dict) -> str:
email = ctx.get("email", "")
email_error = ctx.get("email_error")
- error_html = ""
- if email_error:
- error_html = (
- f''
- f'{escape(email_error)}
'
- )
- content = (
- ''
- '
Check your email '
- f'
We sent a sign-in link to {escape(email)} .
'
- '
Click the link in the email to sign in. The link expires in 15 minutes.
'
- f'{error_html}
'
+ error_html = sexp(
+ '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))',
+ e=str(escape(email_error)),
+ ) if email_error else ""
+
+ content = sexp(
+ '(div :class "py-8 max-w-md mx-auto text-center"'
+ ' (h1 :class "text-2xl font-bold mb-4" "Check your email")'
+ ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")'
+ ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")'
+ ' (raw! err))',
+ email=str(escape(email)), err=error_html,
)
hdr = root_header_html(ctx)
@@ -435,14 +558,19 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
compose_html = ""
if actor:
compose_url = url_for("social.compose_form")
- compose_html = f'Compose '
+ compose_html = sexp(
+ '(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose")',
+ url=compose_url,
+ )
timeline_html = _timeline_items_html(items, timeline_type, actor)
- content = (
- f''
- f'
{label} Timeline {compose_html}'
- f'{timeline_html}
'
+ content = sexp(
+ '(div :class "flex items-center justify-between mb-6"'
+ ' (h1 :class "text-2xl font-bold" (raw! label) " Timeline")'
+ ' (raw! compose))'
+ '(div :id "timeline" (raw! tl))',
+ label=label, compose=compose_html, tl=timeline_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -469,23 +597,27 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
reply_html = ""
if reply_to:
- reply_html = (
- f' '
- f'Replying to {escape(reply_to)}
'
+ reply_html = sexp(
+ '(input :type "hidden" :name "in_reply_to" :value val)'
+ '(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! rt)))',
+ val=str(escape(reply_to)), rt=str(escape(reply_to)),
)
- content = (
- f'Compose '
- f''
+ content = sexp(
+ '(h1 :class "text-2xl font-bold mb-6" "Compose")'
+ '(form :method "post" :action action :class "space-y-4"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (raw! reply)'
+ ' (textarea :name "content" :rows "6" :maxlength "5000" :required true'
+ ' :class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"'
+ ' :placeholder "What\'s on your mind?")'
+ ' (div :class "flex items-center justify-between"'
+ ' (select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm"'
+ ' (option :value "public" "Public")'
+ ' (option :value "unlisted" "Unlisted")'
+ ' (option :value "followers" "Followers only"))'
+ ' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))',
+ action=action, csrf=csrf, reply=reply_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -509,19 +641,30 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
info_html = ""
if query and total:
s = "s" if total != 1 else ""
- info_html = f'{total} result{s} for {escape(query)}
'
+ info_html = sexp(
+ '(p :class "text-sm text-stone-500 mb-4" (raw! t))',
+ t=f"{total} result{s} for {escape(query)} ",
+ )
elif query:
- info_html = f'No results found for {escape(query)}
'
+ info_html = sexp(
+ '(p :class "text-stone-500 mb-4" (raw! t))',
+ t=f"No results found for {escape(query)} ",
+ )
- content = (
- f'Search '
- f''
- f' '
- f'Search
'
- f'{info_html}{results_html}
'
+ content = sexp(
+ '(h1 :class "text-2xl font-bold mb-6" "Search")'
+ '(form :method "get" :action search-url :class "mb-6"'
+ ' :hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url'
+ ' (div :class "flex gap-2"'
+ ' (input :type "text" :name "q" :value query'
+ ' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
+ ' :placeholder "Search users or @user@instance.tld")'
+ ' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search")))'
+ '(raw! info)'
+ '(div :id "search-results" (raw! results))',
+ **{"search-url": search_url, "search-page-url": search_page_url},
+ query=str(escape(query)),
+ info=info_html, results=results_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -542,9 +685,11 @@ async def render_following_page(ctx: dict, actors: list, total: int,
actor: Any) -> str:
"""Full page: following list."""
items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
- content = (
- f'Following ({total}) '
- f'{items_html}
'
+ content = sexp(
+ '(h1 :class "text-2xl font-bold mb-6" "Following "'
+ ' (span :class "text-stone-400 font-normal" "(" (raw! total) ")"))'
+ '(div :id "actor-list" (raw! items))',
+ total=str(total), items=items_html,
)
return _social_page(ctx, actor, content_html=content,
title="Following \u2014 Rose Ash")
@@ -559,9 +704,11 @@ async def render_followers_page(ctx: dict, actors: list, total: int,
followed_urls: set, actor: Any) -> str:
"""Full page: followers list."""
items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
- content = (
- f'Followers ({total}) '
- f'{items_html}
'
+ content = sexp(
+ '(h1 :class "text-2xl font-bold mb-6" "Followers "'
+ ' (span :class "text-stone-400 font-normal" "(" (raw! total) ")"))'
+ '(div :id "actor-list" (raw! items))',
+ total=str(total), items=items_html,
)
return _social_page(ctx, actor, content_html=content,
title="Followers \u2014 Rose Ash")
@@ -590,39 +737,61 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
actor_url = getattr(remote_actor, "actor_url", "")
if icon_url:
- avatar = f' '
+ avatar = sexp(
+ '(img :src src :alt "" :class "w-16 h-16 rounded-full")',
+ src=icon_url,
+ )
else:
initial = display_name[0].upper() if display_name else "?"
- avatar = f'{initial}
'
+ avatar = sexp(
+ '(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl" (raw! i))',
+ i=initial,
+ )
- summary_html = f'{summary}
' if summary else ""
+ summary_html = sexp(
+ '(div :class "text-sm text-stone-600 mt-2" (raw! s))',
+ s=summary,
+ ) if summary else ""
follow_html = ""
if actor:
if is_following:
- follow_html = (
- f'
'
- f' '
- f' '
- f'Unfollow '
+ follow_html = sexp(
+ '(div :class "flex-shrink-0"'
+ ' (form :method "post" :action action'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "actor_url" :value aurl)'
+ ' (button :type "submit" :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100" "Unfollow")))',
+ action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url,
)
else:
- follow_html = (
- f'
'
- f' '
- f' '
- f'Follow '
+ follow_html = sexp(
+ '(div :class "flex-shrink-0"'
+ ' (form :method "post" :action action'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (input :type "hidden" :name "actor_url" :value aurl)'
+ ' (button :type "submit" :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700" "Follow")))',
+ action=url_for("social.follow"), csrf=csrf, aurl=actor_url,
)
timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
- content = (
- f''
- f'
{avatar}'
- f'
{escape(display_name)} '
- f'
@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}
'
- f'{summary_html}
{follow_html}
'
- f'{timeline_html}
'
+ content = sexp(
+ '(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"'
+ ' (div :class "flex items-center gap-4"'
+ ' (raw! avatar)'
+ ' (div :class "flex-1"'
+ ' (h1 :class "text-xl font-bold" (raw! dname))'
+ ' (div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))'
+ ' (raw! summary))'
+ ' (raw! follow)))'
+ '(div :id "timeline" (raw! tl))',
+ avatar=avatar,
+ dname=str(escape(display_name)),
+ username=str(escape(remote_actor.preferred_username)),
+ domain=str(escape(remote_actor.domain)),
+ summary=summary_html, follow=follow_html,
+ tl=timeline_html,
)
return _social_page(ctx, actor, content_html=content,
@@ -643,11 +812,17 @@ async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
if not notifications:
- notif_html = 'No notifications yet.
'
+ notif_html = sexp('(p :class "text-stone-500" "No notifications yet.")')
else:
- notif_html = '' + "".join(_notification_html(n) for n in notifications) + '
'
+ notif_html = sexp(
+ '(div :class "space-y-2" (raw! items))',
+ items="".join(_notification_html(n) for n in notifications),
+ )
- content = f'Notifications {notif_html}'
+ content = sexp(
+ '(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs)',
+ notifs=notif_html,
+ )
return _social_page(ctx, actor, content_html=content,
title="Notifications \u2014 Rose Ash")
@@ -669,25 +844,37 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
- error_html = f'{error}
' if error else ""
+ error_html = sexp(
+ '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))',
+ e=error,
+ ) if error else ""
- content = (
- f''
- f'
Choose your username '
- f'
This will be your identity on the fediverse: '
- f'@username@{escape(ap_domain)}
'
- f'{error_html}'
- f'
'
- f' '
- f'Username '
- f'
@ '
- f' '
- f'
'
- f'
3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.
'
- f'Claim username '
+ content = sexp(
+ '(div :class "py-8 max-w-md mx-auto"'
+ ' (h1 :class "text-2xl font-bold mb-2" "Choose your username")'
+ ' (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "'
+ ' (strong "@username@" (raw! domain)))'
+ ' (raw! err)'
+ ' (form :method "post" :class "space-y-4"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (div'
+ ' (label :for "username" :class "block text-sm font-medium mb-1" "Username")'
+ ' (div :class "flex items-center"'
+ ' (span :class "text-stone-400 mr-1" "@")'
+ ' (input :type "text" :name "username" :id "username" :value uname'
+ ' :pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32"'
+ ' :required true :autocomplete "off"'
+ ' :class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
+ ' :hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status"'
+ ' :hx-include "[name=\'username\']"))'
+ ' (div :id "username-status" :class "text-sm mt-1")'
+ ' (p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter."))'
+ ' (button :type "submit"'
+ ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"'
+ ' "Claim username")))',
+ domain=str(escape(ap_domain)), err=error_html,
+ csrf=csrf, uname=str(escape(username)),
+ **{"check-url": check_url},
)
return _social_page(ctx, actor, content_html=content,
@@ -705,29 +892,49 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username
- summary_html = f'{escape(actor.summary)}
' if actor.summary else ""
+ summary_html = sexp(
+ '(p :class "mt-2" (raw! s))',
+ s=str(escape(actor.summary)),
+ ) if actor.summary else ""
activities_html = ""
if activities:
parts = []
for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
- obj_type = f'{a.object_type} ' if a.object_type else ""
- parts.append(
- f''
- f'{a.activity_type} '
- f'{published}
{obj_type}
'
- )
- activities_html = '' + "".join(parts) + '
'
+ obj_type_html = sexp(
+ '(span :class "text-sm text-stone-500" (raw! t))',
+ t=a.object_type,
+ ) if a.object_type else ""
+ parts.append(sexp(
+ '(div :class "bg-white rounded-lg shadow p-4"'
+ ' (div :class "flex justify-between items-start"'
+ ' (span :class "font-medium" (raw! atype))'
+ ' (span :class "text-sm text-stone-400" (raw! pub)))'
+ ' (raw! otype))',
+ atype=a.activity_type, pub=published,
+ otype=obj_type_html,
+ ))
+ activities_html = sexp(
+ '(div :class "space-y-4" (raw! items))',
+ items="".join(parts),
+ )
else:
- activities_html = 'No activities yet.
'
+ activities_html = sexp('(p :class "text-stone-500" "No activities yet.")')
- content = (
- f''
- f'
{escape(display_name)} '
- f'
@{escape(actor.preferred_username)}@{escape(ap_domain)}
'
- f'{summary_html}
'
- f'
Activities ({total}) {activities_html}
'
+ content = sexp(
+ '(div :class "py-8"'
+ ' (div :class "bg-white rounded-lg shadow p-6 mb-6"'
+ ' (h1 :class "text-2xl font-bold" (raw! dname))'
+ ' (p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))'
+ ' (raw! summary))'
+ ' (h2 :class "text-xl font-bold mb-4" "Activities (" (raw! total) ")")'
+ ' (raw! activities))',
+ dname=str(escape(display_name)),
+ username=str(escape(actor.preferred_username)),
+ domain=str(escape(ap_domain)),
+ summary=summary_html,
+ total=str(total), activities=activities_html,
)
return _social_page(ctx, actor, content_html=content,
diff --git a/orders/sexp/sexp_components.py b/orders/sexp/sexp_components.py
index 058266f..d0e00eb 100644
--- a/orders/sexp/sexp_components.py
+++ b/orders/sexp/sexp_components.py
@@ -69,36 +69,56 @@ def _orders_header_html(ctx: dict, list_url: str) -> str:
# Orders list rendering
# ---------------------------------------------------------------------------
+def _status_pill_cls(status: str) -> str:
+ """Return Tailwind classes for order status pill."""
+ sl = status.lower()
+ if sl == "paid":
+ return "border-emerald-300 bg-emerald-50 text-emerald-700"
+ if sl in ("failed", "cancelled"):
+ return "border-rose-300 bg-rose-50 text-rose-700"
+ return "border-stone-300 bg-stone-50 text-stone-700"
+
+
def _order_row_html(order: Any, detail_url: str) -> str:
"""Render a single order as desktop table row + mobile card."""
status = order.status or "pending"
- sl = status.lower()
- pill = (
- "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
- else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
- else "border-stone-300 bg-stone-50 text-stone-700"
- )
+ pill = _status_pill_cls(status)
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
- return (
- # Desktop row
- f''
- f'#{order.id} '
- f'{created} '
- f'{order.description or ""} '
- f'{total} '
- f'{status} '
- f'View '
- # Mobile row
- f''
- f'
#{order.id} '
- f'{status}
'
- f'
{created}
'
- f'
'
+ desktop = sexp(
+ '(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"'
+ ' (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! oid)))'
+ ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created))'
+ ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc))'
+ ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total))'
+ ' (td :class "px-3 py-2 align-top" (span :class pill (raw! status)))'
+ ' (td :class "px-3 py-0.5 align-top text-right"'
+ ' (a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View")))',
+ oid=f"#{order.id}", created=created,
+ desc=order.description or "", total=total,
+ pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}",
+ status=status, url=detail_url,
)
+ mobile = sexp(
+ '(tr :class "sm:hidden border-t border-stone-100"'
+ ' (td :colspan "5" :class "px-3 py-3"'
+ ' (div :class "flex flex-col gap-2 text-xs"'
+ ' (div :class "flex items-center justify-between gap-2"'
+ ' (span :class "font-mono text-[11px] text-stone-700" (raw! oid))'
+ ' (span :class pill (raw! status)))'
+ ' (div :class "text-[11px] text-stone-500 break-words" (raw! created))'
+ ' (div :class "flex items-center justify-between gap-2"'
+ ' (div :class "font-medium text-stone-800" (raw! total))'
+ ' (a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View")))))',
+ oid=f"#{order.id}", created=created, total=total,
+ pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}",
+ status=status, url=detail_url,
+ )
+
+ return desktop + mobile
+
def _orders_rows_html(orders: list, page: int, total_pages: int,
url_for_fn: Any, qs_fn: Any) -> str:
@@ -118,7 +138,9 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
u=next_url, p=page, **{"total-pages": total_pages},
))
else:
- parts.append('End of results ')
+ parts.append(sexp(
+ '(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))',
+ ))
return "".join(parts)
@@ -126,33 +148,35 @@ def _orders_rows_html(orders: list, page: int, total_pages: int,
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
"""Main panel with table or empty state."""
if not orders:
- return (
- ''
+ return sexp(
+ '(div :class "max-w-full px-3 py-3 space-y-3"'
+ ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"'
+ ' "No orders yet."))',
)
- return (
- ''
- '
'
- '
'
- ''
- 'Order '
- 'Created '
- 'Description '
- 'Total '
- 'Status '
- ' '
- f' {rows_html}
'
+ return sexp(
+ '(div :class "max-w-full px-3 py-3 space-y-3"'
+ ' (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"'
+ ' (table :class "min-w-full text-xs sm:text-sm"'
+ ' (thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"'
+ ' (tr'
+ ' (th :class "px-3 py-2 text-left font-medium" "Order")'
+ ' (th :class "px-3 py-2 text-left font-medium" "Created")'
+ ' (th :class "px-3 py-2 text-left font-medium" "Description")'
+ ' (th :class "px-3 py-2 text-left font-medium" "Total")'
+ ' (th :class "px-3 py-2 text-left font-medium" "Status")'
+ ' (th :class "px-3 py-2 text-left font-medium" "")))'
+ ' (tbody (raw! rows)))))',
+ rows=rows_html,
)
def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list."""
- return (
- ''
+ return sexp(
+ '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"'
+ ' (div :class "space-y-1" (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))'
+ ' (div :class "md:hidden" (raw! sm)))',
+ sm=search_mobile_html(ctx),
)
@@ -234,26 +258,36 @@ def _order_items_html(order: Any) -> str:
items = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
- img = (
- f' '
- if item.product_image else
- 'No image
'
- )
- items.append(
- f''
- f'{img}
'
- f''
- f'
{item.product_title or "Unknown product"}
'
- f'
Product ID: {item.product_id}
'
- f'
Qty: {item.quantity}
'
- f'
{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}
'
- f'
'
- )
- return (
- ''
+ if item.product_image:
+ img = sexp(
+ '(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")',
+ src=item.product_image, alt=item.product_title or "Product image",
+ )
+ else:
+ img = sexp('(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")')
+
+ items.append(sexp(
+ '(li (a :class "w-full py-2 flex gap-3" :href href'
+ ' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))'
+ ' (div :class "flex-1 flex justify-between gap-3"'
+ ' (div'
+ ' (p :class "font-medium" (raw! title))'
+ ' (p :class "text-[11px] text-stone-500" "Product ID: " (raw! pid)))'
+ ' (div :class "text-right whitespace-nowrap"'
+ ' (p "Qty: " (raw! qty))'
+ ' (p (raw! price))))))',
+ href=prod_url, img=img,
+ title=item.product_title or "Unknown product",
+ pid=str(item.product_id),
+ qty=str(item.quantity),
+ price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
+ ))
+
+ return sexp(
+ '(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"'
+ ' (h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")'
+ ' (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items)))',
+ items="".join(items),
)
@@ -273,18 +307,25 @@ def _calendar_items_html(calendar_entries: list | None) -> str:
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
- items.append(
- f''
- f'{e.name}'
- f''
- f'{st.capitalize()}
'
- f'
{ds}
'
- f'\u00a3{e.cost or 0:.2f}
'
- )
- return (
- ''
- 'Calendar bookings in this order '
- f' '
+ items.append(sexp(
+ '(li :class "px-4 py-3 flex items-start justify-between text-sm"'
+ ' (div'
+ ' (div :class "font-medium flex items-center gap-2"'
+ ' (raw! name)'
+ ' (span :class pill (raw! state)))'
+ ' (div :class "text-xs text-stone-500" (raw! ds)))'
+ ' (div :class "ml-4 font-medium" (raw! cost)))',
+ name=e.name,
+ pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
+ state=st.capitalize(), ds=ds,
+ cost=f"\u00a3{e.cost or 0:.2f}",
+ ))
+
+ return sexp(
+ '(section :class "mt-6 space-y-3"'
+ ' (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")'
+ ' (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items)))',
+ items="".join(items),
)
@@ -297,7 +338,11 @@ def _order_main_html(order: Any, calendar_entries: list | None) -> str:
d=order.description, s=order.status, c=order.currency,
ta=f"{order.total_amount:.2f}" if order.total_amount else None,
)
- return f'{summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}
'
+ return sexp(
+ '(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary) (raw! items) (raw! cal))',
+ summary=summary, items=_order_items_html(order),
+ cal=_calendar_items_html(calendar_entries),
+ )
def _order_filter_html(order: Any, list_url: str, recheck_url: str,
@@ -305,20 +350,31 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: str,
"""Filter section for single order detail."""
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
status = order.status or "pending"
- pay = (
- f''
- f' Open payment page '
- ) if status != "paid" else ""
- return (
- ''
+ pay_html = ""
+ if status != "paid":
+ pay_html = sexp(
+ '(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"'
+ ' (i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page")',
+ url=pay_url,
+ )
+
+ return sexp(
+ '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"'
+ ' (div :class "space-y-1"'
+ ' (p :class "text-xs sm:text-sm text-stone-600" "Placed " (raw! created) " \u00b7 Status: " (raw! status)))'
+ ' (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"'
+ ' (a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
+ ' (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")'
+ ' (form :method "post" :action recheck-url :class "inline"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type "submit"'
+ ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
+ ' (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))'
+ ' (raw! pay)))',
+ created=created, status=status,
+ **{"list-url": list_url, "recheck-url": recheck_url},
+ csrf=csrf_token, pay=pay_html,
)
@@ -389,13 +445,10 @@ async def render_order_oob(ctx: dict, order: Any,
# ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str:
- return (
- ''
+ return sexp(
+ '(header :class "mb-6 sm:mb-8"'
+ ' (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")'
+ ' (p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem."))',
)
@@ -403,25 +456,22 @@ def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = ""
if order:
- order_html = (
- f''
- f'Order ID: #{order.id}
'
+ order_html = sexp(
+ '(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid)))',
+ oid=f"#{order.id}",
)
back_url = cart_url("/")
- return (
- ''
- '
'
- f'
Something went wrong.
'
- f'
{err_msg}
'
- f'{order_html}'
- '
'
- '
'
- '
'
+ return sexp(
+ '(div :class "max-w-full px-3 py-3 space-y-4"'
+ ' (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"'
+ ' (p :class "font-medium" "Something went wrong.")'
+ ' (p (raw! msg))'
+ ' (raw! order-html))'
+ ' (div'
+ ' (a :href back-url'
+ ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
+ ' (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))',
+ msg=err_msg, **{"order-html": order_html, "back-url": back_url},
)