Add widget registry for universal UI decoupling

Introduces a widget system where domains register UI fragments into
named slots (container_nav, container_card, account_page, account_link).
Host apps iterate widgets generically without naming any domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 18:04:13 +00:00
parent dfc324b1be
commit 7882644731
18 changed files with 425 additions and 73 deletions

View File

@@ -2,14 +2,16 @@
{% call links.link(coop_url('/auth/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% call links.link(coop_url('/auth/tickets/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
tickets
{% endcall %}
{% call links.link(coop_url('/auth/bookings/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
bookings
{% endcall %}
<div class="relative nav-group">
<a href="{{ cart_url('/orders/') }}" class="{{styles.nav_button}}" data-hx-disable>
orders
</a>
</div>
{% for link in account_nav_links %}
{% if link.external %}
<div class="relative nav-group">
<a href="{{ link.href_fn() }}" class="{{styles.nav_button}}" data-hx-disable>
{{ link.label }}
</a>
</div>
{% else %}
{% call links.link(link.href_fn(), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{{ link.label }}
{% endcall %}
{% endif %}
{% endfor %}

View File

@@ -69,41 +69,10 @@
{% endif %}
</a>
{# Associated Entries - Scrollable list #}
{% if post.associated_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in post.associated_entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{% endif %}
{# Widget-driven card decorations #}
{% for w in widgets.container_cards %}
{% include w.template with context %}
{% endfor %}
{% include '_types/blog/_card/at_bar.html' %}

View File

@@ -1,6 +1,6 @@
{% import 'macros/links.html' as links %}
{# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #}
{% if (associated_entries and associated_entries.entries) or calendars %}
{# Widget-driven container nav — entries, calendars, markets #}
{% if container_nav_widgets %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entries-calendars-nav-wrapper">
{% include '_types/post/admin/_nav_entries.html' %}

View File

@@ -8,7 +8,7 @@
<i class="fa fa-chevron-left"></i>
</button>
{# Entries and Calendars container #}
{# Widget-driven nav items container #}
<div id="associated-items-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
@@ -22,30 +22,10 @@
remove .flex from .entries-nav-arrow
end">
<div class="flex flex-col sm:flex-row gap-1">
{# Associated Entries #}
{% if associated_entries and associated_entries.entries %}
{% include '_types/post/_entry_items.html' with context %}
{% endif %}
{# Calendars #}
{% for calendar in calendars %}
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>{{calendar.name}}</div>
</a>
{% endfor %}
{# Markets #}
{% for m in markets %}
<a
href="{{ market_url('/' + post.slug + '/' + m.slug + '/') }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>{{m.name}}</div>
</a>
{% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %}
{% include wdata.widget.template with context %}
{% endwith %}
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,36 @@
{# Associated entries on blog listing cards — loaded via widget registry #}
{% set widget_entries = post[w.context_key] if post[w.context_key] is defined else [] %}
{% if widget_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in widget_entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{% endif %}

View File

@@ -0,0 +1,38 @@
{# Calendar entries nav items — loaded via widget registry #}
{% set entry_list = ctx.entries if ctx.entries is defined else [] %}
{% set current_page = ctx.page if ctx.page is defined else 1 %}
{% set has_more_entries = ctx.has_more if ctx.has_more is defined else False %}
{% for entry in entry_list %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}"
>
{% if post.feature_image %}
<img src="{{ post.feature_image }}"
alt="{{ post.title }}"
class="w-8 h-8 rounded object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600 truncate">
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
</a>
{% endfor %}
{# Load more entries one at a time until container is full #}
{% if has_more_entries %}
<div id="entries-load-sentinel-{{ current_page }}"
hx-get="{{ url_for('blog.post.widget_paginate', slug=post.slug, widget_domain='calendar', page=current_page + 1) }}"
hx-trigger="intersect once"
hx-swap="beforebegin"
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
class="flex-shrink-0 w-1">
</div>
{% endif %}

View File

@@ -0,0 +1,10 @@
{# Calendar link nav items — loaded via widget registry #}
{% for calendar in ctx.calendars %}
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>{{calendar.name}}</div>
</a>
{% endfor %}

View File

@@ -0,0 +1,9 @@
{# Market link nav items — loaded via widget registry #}
{% for m in ctx.markets %}
<a
href="{{ market_url('/' + post.slug + '/' + m.slug + '/') }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>{{m.name}}</div>
</a>
{% endfor %}