feat: initialize blog app with blueprints and templates
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract blog-specific code from the coop monolith into a standalone
repository. Includes auth, blog, post, admin, menu_items, snippets
blueprints, associated templates, Dockerfile (APP_MODULE=app:app),
entrypoint, and Gitea CI workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:15:56 +00:00
commit 8f7a15186c
128 changed files with 9246 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<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">
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
{# Account header #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
{% if g.user %}
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
{% if g.user.name %}
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
{% endif %}
{% endif %}
</div>
<form action="{{ url_for('auth.logout')|host }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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"></i>
Sign out
</button>
</form>
</div>
{# Labels #}
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
{% if labels %}
<div>
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
<div class="flex flex-wrap gap-2">
{% for label in labels %}
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
{{ label.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('auth.newsletters'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% call links.link(cart_url('/orders/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
orders
{% endcall %}

View File

@@ -0,0 +1,17 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=un.newsletter_id) }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
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
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
></span>
</button>
</div>

View File

@@ -0,0 +1,46 @@
<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</h1>
{% if newsletter_list %}
<div class="divide-y divide-stone-100">
{% for item in newsletter_list %}
<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">{{ item.newsletter.name }}</p>
{% if item.newsletter.description %}
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
{% endif %}
</div>
<div class="ml-4 flex-shrink-0">
{% if item.un %}
{% with un=item.un %}
{% include "_types/auth/_newsletter_toggle.html" %}
{% endwith %}
{% else %}
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=item.newsletter.id) }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
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"></span>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No newsletters available.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,29 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
If an account exists for
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
youll receive a link to sign in. It expires in 15 minutes.
</p>
{% if email_error %}
<div
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
role="alert"
>
<span class="font-medium">Heads up:</span>
<span>{{ email_error }}</span>
</div>
{% endif %}
<p class="mt-6 text-sm">
<a
href="{{ url_for('auth.login_form')|host }}"
class="text-stone-600 dark:text-stone-300 hover:underline"
>
← Back
</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(url_for('auth.account'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends "_types/root/_index.html" %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include "_types/auth/_nav.html" %}
{% endblock %}
{% block content %}
{% include '_types/auth/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Enter your email and well email you a one-time sign-in link.
</p>
{% if error %}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
<form
method="post" action="{{ url_for('auth.start_login')|host }}"
class="mt-6 space-y-5"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
autocomplete="email"
inputmode="email"
>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
>
Send link
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
<div class="flex flex-wrap gap-2 px-4 py-3">
{% if has_access('blog.new_post') %}
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
{% endif %}
{% if g.user and (draft_count or drafts) %}
{% if drafts %}
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a
href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% else %}
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a
href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,115 @@
{% import 'macros/stickers.html' as stick %}
<article class="border-b pb-6 last:border-b-0 relative">
{# ❤️ like button - OUTSIDE the link, aligned with image top #}
{% if g.user %}
<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
<header class="mb-2 text-center">
<h2 class="text-4xl font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-2 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</header>
{% if post.feature_image %}
<div class="mb-4">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full object-cover"
>
</div>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.custom_excerpt }}
</p>
{% else %}
{% if post.excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.excerpt }}
</p>
{% endif %}
{% 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 %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=entry.calendar.slug,
year=entry.start_at.year,
month=entry.start_at.month,
day=entry.start_at.day,
entry_id=entry.id) }}"
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 %}
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,19 @@
<div class="flex flex-row justify-center gap-3">
{% if post.tags %}
<div class="mt-4 flex items-center gap-2">
<div>in</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/tags.html' %}
</ul>
</div>
{% endif %}
<div></div>
{% if post.authors %}
<div class="mt-4 flex items-center gap-2">
<div>by</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/authors.html' %}
</ul>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,21 @@
{% macro author(author) %}
{% if author %}
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6"></div>
{# optional fallback circle with first letter
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div> #}
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ author.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,32 @@
{# --- AUTHORS LIST STARTS HERE --- #}
{% if post.authors and post.authors|length %}
{% for a in post.authors %}
{% for author in authors if author.slug==a.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_author': author.slug }|qs|host}}"
>
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- AUTHOR LIST ENDS HERE --- #}

View File

@@ -0,0 +1,19 @@
{% macro tag(tag) %}
{% if tag %}
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ tag.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,22 @@
{% macro tag_group(group) %}
{% if group %}
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,17 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# --- TAG LIST STARTS HERE --- #}
{% if post.tags and post.tags|length %}
{% for t in post.tags %}
{% for tag in tags if tag.slug==t.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_tag': tag.slug }|qs|host}}"
>
{{dotag.tag(tag)}}
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- TAG LIST ENDS HERE --- #}

View File

@@ -0,0 +1,59 @@
<article class="relative">
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
{% if post.feature_image %}
<div>
<img
src="{{ post.feature_image }}"
alt=""
class="w-full aspect-video object-cover"
>
</div>
{% endif %}
<div class="p-3 text-center">
<h2 class="text-lg font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-1 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.custom_excerpt }}
</p>
{% elif post.excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.excerpt }}
</p>
{% endif %}
</div>
</a>
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,111 @@
{% for post in posts %}
{% if view == 'tile' %}
{% include "_types/blog/_card_tile.html" %}
{% else %}
{% include "_types/blog/_card.html" %}
{% endif %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
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()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -0,0 +1,48 @@
{# View toggle bar - desktop only #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('blog_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('blog_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if view == 'tile' %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/blog/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/blog/_cards.html" %}
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -0,0 +1,40 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob_.html' import root_header with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'blog-header-child', '_types/blog/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{# Filter container - blog doesn't have child_summary but still needs this element #}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{# Aside with filters #}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/root/_nav.html' %}
{% include '_types/root/_nav_panel.html' %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,79 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
{# --- Edit group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.save', id=group.id) }}"
class="border rounded p-4 bg-white space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Name</label>
<input
type="text" name="name" value="{{ group.name }}" required
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-stone-600 mb-1">Colour</label>
<input
type="text" name="colour" value="{{ group.colour or '' }}" placeholder="#hex"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="w-24">
<label class="block text-xs font-medium text-stone-600 mb-1">Order</label>
<input
type="number" name="sort_order" value="{{ group.sort_order }}"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Feature Image URL</label>
<input
type="text" name="feature_image" value="{{ group.feature_image or '' }}"
placeholder="https://..."
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
{# --- Tag checkboxes --- #}
<div>
<label class="block text-xs font-medium text-stone-600 mb-2">Assign Tags</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2">
{% for tag in all_tags %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer">
<input
type="checkbox" name="tag_ids" value="{{ tag.id }}"
{% if tag.id in assigned_tag_ids %}checked{% endif %}
class="rounded border-stone-300"
>
{% if tag.feature_image %}
<img src="{{ tag.feature_image }}" alt="" class="h-4 w-4 rounded-full object-cover">
{% endif %}
<span>{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-3">
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Save
</button>
</div>
</form>
{# --- Delete form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.delete_group', id=group.id) }}"
class="border-t pt-4"
onsubmit="return confirm('Delete this tag group? Tags will not be deleted.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="border rounded px-4 py-2 bg-red-600 text-white text-sm">
Delete Group
</button>
</form>
</div>

View File

@@ -0,0 +1,17 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('tag-groups-header-child', 'tag-groups-edit-child', '_types/blog/admin/tag_groups/_edit_header.html')}}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,73 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
{# --- Create new group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.create') }}" class="border rounded p-4 bg-white space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h3 class="text-sm font-semibold text-stone-700">New Group</h3>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text" name="name" placeholder="Group name" required
class="flex-1 border rounded px-3 py-2 text-sm"
>
<input
type="text" name="colour" placeholder="#colour"
class="w-28 border rounded px-3 py-2 text-sm"
>
<input
type="number" name="sort_order" placeholder="Order" value="0"
class="w-20 border rounded px-3 py-2 text-sm"
>
</div>
<input
type="text" name="feature_image" placeholder="Image URL (optional)"
class="w-full border rounded px-3 py-2 text-sm"
>
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Create
</button>
</form>
{# --- Existing groups list --- #}
{% if groups %}
<ul class="space-y-2">
{% for group in groups %}
<li class="border rounded p-3 bg-white flex items-center gap-3">
{% if group.feature_image %}
<img src="{{ group.feature_image }}" alt="{{ group.name }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">
{% else %}
<div class="h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}">
{{ group.name[:1] }}
</div>
{% endif %}
<div class="flex-1">
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}"
class="font-medium text-stone-800 hover:underline">
{{ group.name }}
</a>
<span class="text-xs text-stone-500 ml-2">{{ group.slug }}</span>
</div>
<span class="text-xs text-stone-500">order: {{ group.sort_order }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-stone-500 text-sm">No tag groups yet.</p>
{% endif %}
{# --- Unassigned tags --- #}
{% if unassigned_tags %}
<div class="border-t pt-4">
<h3 class="text-sm font-semibold text-stone-700 mb-2">Unassigned Tags ({{ unassigned_tags|length }})</h3>
<div class="flex flex-wrap gap-2">
{% for tag in unassigned_tags %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded">
{{ tag.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,16 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends '_types/blog/admin/tag_groups/index.html' %}
{% block tag_groups_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_edit_header.html' import header_row with context %}
{{ header_row() }}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_header.html' import header_row with context %}
{{ header_row() }}
<div id="tag-groups-header-child">
{% block tag_groups_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% include '_types/blog/_action_buttons.html' %}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
</div>

View File

@@ -0,0 +1,62 @@
{% import '_types/blog/_card/author.html' as doauthor %}
{# Author filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_authors | length == 0) %}
{% set href =
{
'remove_author': selected_authors,
}|qs
|host %}
<a
class="px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any author
</a>
</li>
{% for author in authors %}
<li>
{% set is_on = (selected_authors and (author.slug in selected_authors)) %}
{% set qs = {"remove_author": author.slug, "page":None}|qs if is_on
else {"add_author": author.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{doauthor.author(author)}}
{% if False and author.bio %}
<span class="inline-block flex-1 bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{% if author.bio|length > 50 %}
{{ author.bio[:50] ~ "…" }}
{% else %}
{{ author.bio }}
{% endif %}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,70 @@
{# Tag group filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_groups | length == 0 and selected_tags | length == 0) %}
{% set href =
{
'remove_group': selected_groups,
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Topic
</a>
</li>
{% for group in tag_groups %}
{% if group.post_count > 0 or (selected_groups and group.slug in selected_groups) %}
<li>
{% set is_on = (selected_groups and (group.slug in selected_groups)) %}
{% set qs = {"remove_group": group.slug, "page":None}|qs if is_on
else {"add_group": group.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
<span class="flex-1"></span>
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.post_count }}
</span>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,59 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# Tag filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_tags | length == 0) %}
{% set href =
{
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Tag
</a>
</li>
{% for tag in tags %}
<li>
{% set is_on = (selected_tags and (tag.slug in selected_tags)) %}
{% set qs = {"remove_tag": tag.slug, "page":None}|qs if is_on
else {"add_tag": tag.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{dotag.tag(tag)}}
{% if False and tag.description %}
<span class="flex-1 inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.description }}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='blog-row', oob=oob) %}
<div></div>
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,37 @@
{% extends '_types/root/_index.html' %}
{% block meta %}
{{ super() }}
<script>
(function() {
var p = new URLSearchParams(window.location.search);
if (!p.has('view')
&& window.matchMedia('(min-width: 768px)').matches
&& localStorage.getItem('blog_view') === 'tile') {
p.set('view', 'tile');
window.location.replace(window.location.pathname + '?' + p.toString());
}
})();
</script>
{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% block root_blog_header %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
<div class="md:hidden mx-2 bg-stone-200 rounded">
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
<i class="fa-solid fa-filter"></i>
</span>
<span>
<svg aria-hidden="true" viewBox="0 0 24 24"
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
<path d="M6 9l6 6 6-6" fill="currentColor"/>
</svg>
</span>
</div>

View File

@@ -0,0 +1,14 @@
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
{% include '_types/blog/mobile/_filter/summary/tag_groups.html' %}
{% include '_types/blog/mobile/_filter/summary/authors.html' %}
{% endcall %}
{% include '_types/blog/_action_buttons.html' %}
<div id="filter-details-mobile" style="display:contents">
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
{% endcall %}

View File

@@ -0,0 +1,31 @@
{% if selected_authors and selected_authors|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_authors %}
{% for author in authors %}
{% if st == author.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
<span>
{{author.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,33 @@
{% if selected_groups and selected_groups|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for sg in selected_groups %}
{% for group in tag_groups %}
{% if sg == group.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.name }}
</span>
<span>
{{group.post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,31 @@
{% if selected_tags and selected_tags|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_tags %}
{% for tag in tags %}
{% if st == tag.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.name }}
</span>
<span>
{{tag.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,22 @@
{% extends '_types/root/_index.html' %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8 text-center">
<div class="text-6xl mb-4">📝</div>
<h1 class="text-2xl font-bold text-stone-800 mb-2">Post Not Found</h1>
<p class="text-stone-600 mb-6">
The post "{{ slug }}" could not be found.
</p>
<a
href="{{ url_for('blog.home')|host }}"
hx-get="{{ url_for('blog.home')|host }}"
hx-target="#main-panel"
hx-select="{{ hx_select }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
>
← Back to Blog
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
<div class="p-4 space-y-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
</div>
{% if drafts %}
<div class="space-y-3">
{% for draft in drafts %}
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
<a
href="{{ edit_href }}"
hx-boost="false"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-stone-900 truncate">
{{ draft.title or "Untitled" }}
</h3>
{% if draft.excerpt %}
<p class="text-stone-600 text-sm mt-1 line-clamp-2">
{{ draft.excerpt }}
</p>
{% endif %}
{% if draft.updated_at %}
<p class="text-xs text-stone-400 mt-2">
Updated: {{ draft.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 flex-shrink-0">
Draft
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500 text-center py-8">No drafts yet.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,12 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,259 @@
{# ── Error banner ── #}
{% if save_error %}
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
<strong>Save failed:</strong> {{ save_error }}
</div>
{% endif %}
<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" id="lexical-json-input" name="lexical" value="">
<input type="hidden" id="feature-image-input" name="feature_image" value="">
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">
{# ── Feature image ── #}
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
{# Empty state: add link #}
<div id="feature-image-empty">
<button
type="button"
id="feature-image-add-btn"
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
>+ Add feature image</button>
</div>
{# Filled state: image preview + controls #}
<div id="feature-image-filled" class="relative hidden">
<img
id="feature-image-preview"
src=""
alt=""
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
>
{# Delete button (top-right, visible on hover) #}
<button
type="button"
id="feature-image-delete-btn"
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
flex items-center justify-center opacity-0 group-hover:opacity-100
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
title="Remove feature image"
><i class="fa-solid fa-trash-can"></i></button>
{# Caption input #}
<input
type="text"
id="feature-image-caption"
value=""
placeholder="Add a caption..."
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
outline-none placeholder:text-stone-300 focus:text-stone-700"
>
</div>
{# Upload spinner overlay #}
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
</div>
{# Hidden file input #}
<input
type="file"
id="feature-image-file"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
class="hidden"
>
</div>
{# ── Title ── #}
<input
type="text"
name="title"
value=""
placeholder="Post title..."
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
placeholder:text-stone-300 mb-[8px] leading-tight"
>
{# ── Excerpt ── #}
<textarea
name="custom_excerpt"
rows="1"
placeholder="Add an excerpt..."
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
></textarea>
{# ── Editor mount point ── #}
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
{# ── Status + Save footer ── #}
<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
<select
name="status"
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
>
<option value="draft" selected>Draft</option>
<option value="published">Published</option>
</select>
<button
type="submit"
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
hover:bg-stone-800 transition-colors cursor-pointer"
>Create Post</button>
</div>
</form>
{# ── Koenig editor assets ── #}
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
<style>
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
We apply that via JS (see init() below) so the header bars render at
normal size on first paint. A beforeSwap listener restores the
default when navigating away. */
#lexical-editor { display: flow-root; }
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
#lexical-editor [data-kg-card="html"] * { float: none !important; }
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
</style>
<script src="{{ asset_url('scripts/editor.js') }}"></script>
<script>
(function() {
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
restore default when navigating away via HTMX ── */
function applyEditorFontSize() {
document.documentElement.style.fontSize = '62.5%';
document.body.style.fontSize = '1.6rem';
}
function restoreDefaultFontSize() {
document.documentElement.style.fontSize = '';
document.body.style.fontSize = '';
}
applyEditorFontSize();
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
if (e.detail.target && e.detail.target.id === 'main-panel') {
restoreDefaultFontSize();
document.body.removeEventListener('htmx:beforeSwap', cleanup);
}
});
function init() {
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
var uploadUrls = {
image: uploadUrl,
media: '{{ url_for("blog.editor_api.upload_media") }}',
file: '{{ url_for("blog.editor_api.upload_file") }}',
};
/* ── Feature image upload / delete / replace ── */
var fileInput = document.getElementById('feature-image-file');
var addBtn = document.getElementById('feature-image-add-btn');
var deleteBtn = document.getElementById('feature-image-delete-btn');
var preview = document.getElementById('feature-image-preview');
var emptyState = document.getElementById('feature-image-empty');
var filledState = document.getElementById('feature-image-filled');
var hiddenUrl = document.getElementById('feature-image-input');
var hiddenCaption = document.getElementById('feature-image-caption-input');
var captionInput = document.getElementById('feature-image-caption');
var uploading = document.getElementById('feature-image-uploading');
function showFilled(url) {
preview.src = url;
hiddenUrl.value = url;
emptyState.classList.add('hidden');
filledState.classList.remove('hidden');
uploading.classList.add('hidden');
}
function showEmpty() {
preview.src = '';
hiddenUrl.value = '';
hiddenCaption.value = '';
captionInput.value = '';
emptyState.classList.remove('hidden');
filledState.classList.add('hidden');
uploading.classList.add('hidden');
}
function uploadFile(file) {
emptyState.classList.add('hidden');
uploading.classList.remove('hidden');
var fd = new FormData();
fd.append('file', file);
fetch(uploadUrl, {
method: 'POST',
body: fd,
headers: { 'X-CSRFToken': csrfToken },
})
.then(function(r) {
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
return r.json();
})
.then(function(data) {
var url = data.images && data.images[0] && data.images[0].url;
if (url) showFilled(url);
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
})
.catch(function(e) {
showEmpty();
alert(e.message);
});
}
addBtn.addEventListener('click', function() { fileInput.click(); });
preview.addEventListener('click', function() { fileInput.click(); });
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
showEmpty();
});
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) {
uploadFile(fileInput.files[0]);
fileInput.value = '';
}
});
captionInput.addEventListener('input', function() {
hiddenCaption.value = captionInput.value;
});
/* ── Auto-resize excerpt textarea ── */
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
function autoResize() {
excerpt.style.height = 'auto';
excerpt.style.height = excerpt.scrollHeight + 'px';
}
excerpt.addEventListener('input', autoResize);
autoResize();
/* ── Mount Koenig editor ── */
window.mountEditor('lexical-editor', {
initialJson: null,
csrfToken: csrfToken,
uploadUrls: uploadUrls,
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
unsplashApiKey: '{{ unsplash_api_key or "" }}',
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
});
/* ── Ctrl-S / Cmd-S to save ── */
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
document.getElementById('post-new-form').requestSubmit();
}
});
}
/* editor.js loads synchronously on full page loads but asynchronously
when HTMX swaps the content in, so wait for it if needed. */
if (typeof window.mountEditor === 'function') {
init();
} else {
var _t = setInterval(function() {
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
}, 50);
}
})();
</script>

View File

@@ -0,0 +1,12 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,125 @@
<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">
{% if menu_item %}Edit{% else %}Add{% endif %} Menu Item
</h2>
<button
type="button"
onclick="document.getElementById('menu-item-form').innerHTML = ''"
class="text-stone-400 hover:text-stone-600">
<i class="fa fa-times"></i>
</button>
</div>
{# Hidden field for selected post ID - outside form for JS access #}
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.post_id if menu_item else '' }}" />
{# Selected page display #}
{% if menu_item %}
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
{% if menu_item.post.feature_image %}
<img src="{{ menu_item.post.feature_image }}"
alt="{{ menu_item.post.title }}"
class="w-10 h-10 rounded-full object-cover" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium">{{ menu_item.post.title }}</div>
<div class="text-xs text-stone-500">{{ menu_item.post.slug }}</div>
</div>
</div>
{% else %}
<div id="selected-page-display" class="mb-3 hidden">
{# Will be populated by JavaScript when page selected #}
</div>
{% endif %}
{# Form for submission #}
<form
{% if menu_item %}
hx-put="{{ url_for('menu_items.update_menu_item_route', item_id=menu_item.id) }}"
{% else %}
hx-post="{{ url_for('menu_items.create_menu_item_route') }}"
{% endif %}
hx-target="#menu-items-list"
hx-swap="innerHTML"
hx-include="#selected-post-id"
hx-on::after-request="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }"
class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Form actions #}
<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="document.getElementById('menu-item-form').innerHTML = ''"
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">
Cancel
</button>
</div>
</form>
{# Search section - outside form to prevent interference #}
<div class="mt-4">
<label class="block text-sm font-medium text-stone-700 mb-2">
Select Page
</label>
{# Search input #}
<input
type="text"
placeholder="Search for a page... (or leave blank for all)"
hx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="keyup changed delay:300ms, focus once"
hx-target="#page-search-results"
hx-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" />
{# Search results container #}
<div id="page-search-results" class="mt-2">
{# Search results will appear here #}
</div>
</div>
</div>
<script>
// Handle page selection
document.addEventListener('click', function(e) {
const pageOption = e.target.closest('[data-page-id]');
if (pageOption) {
const postId = pageOption.dataset.pageId;
const postTitle = pageOption.dataset.pageTitle;
const postSlug = pageOption.dataset.pageSlug;
const postImage = pageOption.dataset.pageImage;
// Update hidden field
document.getElementById('selected-post-id').value = postId;
// Update display
const 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');
// Clear search results
document.getElementById('page-search-results').innerHTML = '';
}
});
</script>

View File

@@ -0,0 +1,68 @@
<div class="bg-white rounded-lg shadow">
{% if menu_items %}
<div class="divide-y">
{% for item in menu_items %}
<div class="flex items-center gap-4 p-4 hover:bg-stone-50 transition">
{# Drag handle #}
<div class="text-stone-400 cursor-move">
<i class="fa fa-grip-vertical"></i>
</div>
{# Page image #}
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
{# Page title #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ item.post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.post.slug }}</div>
</div>
{# Sort order #}
<div class="text-sm text-stone-500">
Order: {{ item.sort_order }}
</div>
{# Actions #}
<div class="flex gap-2 flex-shrink-0">
<button
type="button"
hx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded">
<i class="fa fa-edit"></i> Edit
</button>
<button
type="button"
data-confirm
data-confirm-title="Delete menu item?"
data-confirm-text="Remove {{ item.post.title }} from the menu?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}"
hx-trigger="confirmed"
hx-target="#menu-items-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800">
<i class="fa fa-trash"></i> Delete
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-stone-400">
<i class="fa fa-inbox text-4xl mb-2"></i>
<p>No menu items yet. Add one to get started!</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,20 @@
<div class="max-w-4xl mx-auto p-6">
<div class="mb-6 flex justify-end items-center">
<button
type="button"
hx-get="{{ url_for('menu_items.new_menu_item') }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-plus"></i> Add Menu Item
</button>
</div>
{# Form container #}
<div id="menu-item-form" class="mb-6"></div>
{# Menu items list #}
<div id="menu-items-list">
{% include '_types/menu_items/_list.html' %}
</div>
</div>

View File

@@ -0,0 +1,29 @@
{% set _app_slugs = {'market': market_url('/'), 'cart': cart_url('/')} %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="menu-items-nav-wrapper"
hx-swap-oob="outerHTML">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.post.slug, coop_url('/' + item.post.slug + '/')) %}
<a
href="{{ _href }}"
{% if item.post.slug not in _app_slugs %}
hx-get="/{{ item.post.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
{% endif %}
class="{{styles.nav_button}}"
>
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.post.title }}</span>
</a>
{% endcall %}
</div>

View File

@@ -0,0 +1,23 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'menu_items-header-child', '_types/menu_items/header/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{#% include '_types/root/settings/_nav.html' %#}
{% endblock %}
{% block content %}
{% include '_types/menu_items/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% if pages %}
<div class="border border-stone-200 rounded-md max-h-64 overflow-y-auto">
{% for post in pages %}
<div
class="flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
data-page-id="{{ post.id }}"
data-page-title="{{ post.title }}"
data-page-slug="{{ post.slug }}"
data-page-image="{{ post.feature_image or '' }}">
{# Page image #}
{% if post.feature_image %}
<img src="{{ post.feature_image }}"
alt="{{ post.title }}"
class="w-10 h-10 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
{# Page info #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ post.slug }}</div>
</div>
</div>
{% endfor %}
{# Infinite scroll sentinel #}
{% if has_more %}
<div
hx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-vals='{"q": "{{ query }}", "page": {{ page + 1 }}}'
class="p-3 text-center text-sm text-stone-400">
<i class="fa fa-spinner fa-spin"></i> Loading more...
</div>
{% endif %}
</div>
{% elif query %}
<div class="p-3 text-center text-stone-400 border border-stone-200 rounded-md">
No pages found matching "{{ query }}"
</div>
{% endif %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='menu_items-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/menu_items/header/_header.html' import header_row with context %}
{{ header_row() }}
<div id="menu_items-header-child">
{% block menu_items_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/menu_items/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

View File

@@ -0,0 +1,24 @@
<div id="associated-entries-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;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
end">
<div class="flex flex-col sm:flex-row gap-1">
{% include '_types/post/_entry_items.html' with context %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,43 @@
{# Get entries from either direct variable or associated_entries dict #}
{% set entry_list = entries if entries is defined else (associated_entries.entries if associated_entries is defined else []) %}
{% set current_page = page if page is defined else (associated_entries.page if associated_entries is defined else 1) %}
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
{% for entry in entry_list %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=entry.calendar.post.slug,
calendar_slug=entry.calendar.slug,
year=entry.start_at.year,
month=entry.start_at.month,
day=entry.start_at.day,
entry_id=entry.id) }}"
class="{{styles.nav_button_less_pad}}"
>
{% if entry.calendar.post.feature_image %}
<img src="{{ entry.calendar.post.feature_image }}"
alt="{{ entry.calendar.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.get_entries', slug=post.slug, 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,62 @@
{# Main panel fragment for HTMX navigation - post article content #}
<article class="relative">
{# ❤️ like button - always visible in top right of article #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{# Draft indicator + edit link #}
{% if post.status == "draft" %}
<div class="flex items-center justify-center gap-2 mb-3">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
{% set is_admin = (g.get("rights") or {}).get("admin") %}
{% if is_admin or (g.user and post.user_id == g.user.id) %}
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
<a
href="{{ edit_href }}"
hx-get="{{ edit_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
>
<i class="fa fa-pencil mr-1"></i> Edit
</a>
{% endif %}
</div>
{% endif %}
{% if post.custom_excerpt %}
<div class="w-full text-center italic text-3xl p-2">
{{post.custom_excerpt|safe}}
</div>
{% endif %}
<div class="hidden md:block">
{% include '_types/blog/_card/at_bar.html' %}
</div>
{% if post.feature_image %}
<div class="mb-3 flex justify-center">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full md:w-3/4 object-cover"
>
</div>
{% endif %}
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
<div class="pb-8"></div>

View File

@@ -0,0 +1,124 @@
{# --- social/meta_post.html --- #}
{# Context expected:
site, post, request
#}
{# Visibility → robots #}
{% set is_public = (post.visibility == 'public') %}
{% set is_published = (post.status == 'published') %}
{% set robots_here = 'index,follow' if (is_public and is_published and not post.email_only) else 'noindex,nofollow' %}
{# Compute canonical early so both this file and base can use it #}
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
{% set _post_path = request.path if request else ('/posts/' ~ (post.slug or post.uuid)) %}
{% set canonical = post.canonical_url or (_site_url ~ _post_path if _site_url else (request.url if request else None)) %}
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
{% set robots_override = robots_here %}
{% include 'social/meta_base.html' %}
{# ---- Titles / descriptions ---- #}
{% set og_title = post.og_title or base_title %}
{% set tw_title = post.twitter_title or base_title %}
{# Description best-effort, trimmed #}
{% set desc_source = post.meta_description
or post.og_description
or post.twitter_description
or post.custom_excerpt
or post.excerpt
or (post.plaintext if post.plaintext else (post.html|striptags if post.html else '')) %}
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
{# Image priority #}
{% set image_url = post.og_image
or post.twitter_image
or post.feature_image
or (site().default_image if site and site().default_image else None) %}
{# Dates #}
{% set published_iso = post.published_at.isoformat() if post.published_at else None %}
{% set updated_iso = post.updated_at.isoformat() if post.updated_at
else (post.created_at.isoformat() if post.created_at else None) %}
{# Authors / tags #}
{% set primary_author = post.primary_author %}
{% set authors = post.authors or ([primary_author] if primary_author else []) %}
{% set tag_names = (post.tags or []) | map(attribute='name') | list %}
{% set is_article = not post.is_page %}
<title>{{ base_title }}</title>
<meta name="description" content="{{ description }}">
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{# ---- Open Graph ---- #}
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
<meta property="og:type" content="{{ 'article' if is_article else 'website' }}">
<meta property="og:title" content="{{ og_title }}">
<meta property="og:description" content="{{ description }}">
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
{% if is_article and published_iso %}<meta property="article:published_time" content="{{ published_iso }}">{% endif %}
{% if is_article and updated_iso %}
<meta property="article:modified_time" content="{{ updated_iso }}">
<meta property="og:updatd_time" content="{{ updated_iso }}">
{% endif %}
{% if is_article and post.primary_tag and post.primary_tag.name %}
<meta property="article:section" content="{{ post.primary_tag.name }}">
{% endif %}
{% if is_article %}
{% for t in tag_names %}
<meta property="article:tag" content="{{ t }}">
{% endfor %}
{% endif %}
{# ---- Twitter ---- #}
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
{% if primary_author and primary_author.twitter %}
<meta name="twitter:creator" content="@{{ primary_author.twitter | replace('@','') }}">
{% endif %}
<meta name="twitter:title" content="{{ tw_title }}">
<meta name="twitter:description" content="{{ description }}">
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
{# ---- JSON-LD author value (no list comprehensions) ---- #}
{% if authors and authors|length == 1 %}
{% set author_value = {"@type": "Person", "name": authors[0].name} %}
{% elif authors %}
{% set ns = namespace(arr=[]) %}
{% for a in authors %}
{% set _ = ns.arr.append({"@type": "Person", "name": a.name}) %}
{% endfor %}
{% set author_value = ns.arr %}
{% else %}
{% set author_value = none %}
{% endif %}
{# ---- JSON-LD using combine for optionals ---- #}
{% set jsonld = {
"@context": "https://schema.org",
"@type": "BlogPosting" if is_article else "WebPage",
"mainEntityOfPage": canonical,
"headline": base_title,
"description": description,
"image": image_url,
"datePublished": published_iso,
"author": author_value,
"publisher": {
"@type": "Organization",
"name": site().title if site and site().title else "",
"logo": {"@type": "ImageObject", "url": site().logo if site and site().logo else image_url}
}
} %}
{% if updated_iso %}
{% set jsonld = jsonld | combine({"dateModified": updated_iso}) %}
{% endif %}
{% if tag_names %}
{% set jsonld = jsonld | combine({"keywords": tag_names | join(", ")}) %}
{% endif %}
<script type="application/ld+json">
{{ jsonld | tojson }}
</script>

View File

@@ -0,0 +1,16 @@
{% 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 %}
<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' %}
</div>
{% endif %}
{# Admin link #}
{% if post and has_access('blog.post.admin.admin') %}
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
{% endcall %}
{% endif %}

View File

@@ -0,0 +1,36 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header(id='root-header-child', oob=True) %}
{% call header() %}
{% from '_types/post/header/_header.html' import header_row with context %}
{{header_row()}}
<div id="post-header-child">
</div>
{% endcall %}
{% endcall %}
{# Mobile menu #}
{% block mobile_menu %}
{% include '_types/post/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/post/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,50 @@
<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">
<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>
{% if associated_entry_ids %}
<div class="space-y-1">
{% for calendar in all_calendars %}
{% for entry in calendar.entries %}
{% if entry.id in associated_entry_ids and entry.deleted_at is none %}
<button
type="button"
class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
data-confirm
data-confirm-title="Remove entry?"
data-confirm-text="This will remove {{ entry.name }} from this post"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<div class="flex items-center justify-between gap-3">
{% if calendar.post.feature_image %}
<img src="{{ calendar.post.feature_image }}"
alt="{{ calendar.post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium text-sm">{{ entry.name }}</div>
<div class="text-xs text-stone-600 mt-1">
{{ calendar.name }} • {{ entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>
</div>
</button>
{% endif %}
{% endfor %}
{% endfor %}
</div>
{% else %}
<div class="text-sm text-stone-400">No entries associated yet. Browse calendars below to add entries.</div>
{% endif %}
</div>

View File

@@ -0,0 +1,88 @@
<div id="calendar-view-{{ calendar.id }}"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}"
hx-trigger="entryToggled from:body"
hx-swap="outerHTML">
{# Month/year navigation #}
<header class="flex items-center justify-center mb-4">
<nav class="flex items-center gap-2 text-xl">
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&laquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_month_year }}&month={{ prev_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_month_year, month=prev_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&lsaquo;</a>
<div class="px-3 font-medium">{{ month_name }} {{ year }}</div>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_month_year }}&month={{ next_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_month_year, month=next_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&rsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&raquo;</a>
</nav>
</header>
{# Calendar grid #}
<div class="rounded border bg-white">
<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">
{% for wd in weekday_names %}
<div class="py-2">{{ wd }}</div>
{% endfor %}
</div>
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">
{% for week in weeks %}
{% for day in week %}
<div class="min-h-20 bg-white px-2 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %}">
<div class="font-medium mb-1">{{ day.date.day }}</div>
{# Entries for this day #}
<div class="space-y-0.5">
{% for e in month_entries %}
{% if e.start_at.date() == day.date and e.deleted_at is none %}
{% if e.id in associated_entry_ids %}
{# Associated entry - show with delete button #}
<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">
<span class="truncate flex-1">{{ e.name }}</span>
<button
type="button"
class="flex-shrink-0 hover:text-red-600"
data-confirm
data-confirm-title="Remove entry?"
data-confirm-text="Remove {{ e.name }} from this post?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<i class="fa fa-times"></i>
</button>
</div>
{% else %}
{# Non-associated entry - clickable to add #}
<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"
data-confirm
data-confirm-title="Add entry?"
data-confirm-text="Add {{ e.name }} to this post?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<span class="truncate block">{{ e.name }}</span>
</button>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</div>

View File

@@ -0,0 +1,7 @@
{# Main panel fragment for HTMX navigation - post admin #}
<section
id="main-panel"
class="flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
>
<div class="pb-8"></div>
</section>

View File

@@ -0,0 +1,16 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.calendars.home', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
calendars
{% endcall %}
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
entries
{% endcall %}
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
data
{% endcall %}
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
edit
{% endcall %}
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
settings
{% endcall %}

View File

@@ -0,0 +1,66 @@
{# Left scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
<i class="fa fa-chevron-left"></i>
</button>
{# Entries and Calendars 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;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
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">
<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=url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug=calendar.slug) %}
{% set href=local_href|host %}
<a
href="{{ local_href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>{{calendar.name}}</div>
</a>
{% endfor %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{# Right scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
<i class="fa fa-chevron-right"></i>
</button>

View File

@@ -0,0 +1,14 @@
{# OOB swap for nav entries and calendars when toggling associations or editing calendars #}
{% 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 %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entries-calendars-nav-wrapper"
hx-swap-oob="true">
{% include '_types/post/admin/_nav_entries.html' %}
</div>
{% else %}
{# Empty placeholder to remove nav items when all are disassociated/deleted #}
<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>
{% endif %}

View File

@@ -0,0 +1,22 @@
{% extends "oob_elements.html" %}
{# OOB elements for post admin page #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-header-child', 'post-admin-header-child', '_types/post/admin/header/_header.html')}}
{% from '_types/post/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/post/admin/_nav.html' %}
{% endblock %}
{% block content %}
nowt
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post-admin-row', oob=oob) %}
{% call links.link(
url_for('blog.post.admin.admin', slug=post.slug),
hx_select_search) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/post/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends '_types/post/index.html' %}
{% import 'macros/layout.html' as layout %}
{% block post_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
{% block post_admin_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/post/admin/_nav.html' %}
{% endblock %}
{% block content %}
nowt
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post-row', oob=oob) %}
{% call links.link(url_for('blog.post.post_detail', slug=post.slug), hx_select_search ) %}
{% if post.feature_image %}
<img
src="{{ post.feature_image }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% endif %}
<span>
{{ post.title | truncate(160, True, '…') }}
</span>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/post/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,25 @@
{% extends '_types/root/_index.html' %}
{% import 'macros/layout.html' as layout %}
{% block meta %}
{% include '_types/post/_meta.html' %}
{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% block post_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/post/_nav.html' %}
{% endblock %}
{% block aside %}
{% endblock %}
{% block content %}
{% include '_types/post/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
<div id="post-entries-content" class="space-y-6 p-4">
{# Associated Entries List #}
{% include '_types/post/admin/_associated_entries.html' %}
{# Calendars Browser #}
<div class="space-y-3">
<h3 class="text-lg font-semibold">Browse Calendars</h3>
{% for calendar in all_calendars %}
<details class="border rounded-lg bg-white"
_="on toggle
if my.open
for other in <details[open]/>
if other is not me
set other.open to false
end
end
end">
<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">
{% if calendar.post.feature_image %}
<img src="{{ calendar.post.feature_image }}"
alt="{{ calendar.post.title }}"
class="w-12 h-12 rounded object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1">
<div class="font-semibold flex items-center gap-2">
<i class="fa fa-calendar text-stone-500"></i>
{{ calendar.name }}
</div>
<div class="text-sm text-stone-600">
{{ calendar.post.title }}
</div>
</div>
</summary>
<div class="p-4 border-t"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
hx-trigger="intersect once"
hx-swap="innerHTML">
<div class="text-sm text-stone-400">Loading calendar...</div>
</div>
</details>
{% else %}
<div class="text-sm text-stone-400">No calendars found.</div>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,28 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-admin-header-child', 'post_entries-header-child', '_types/post_entries/header/_header.html')}}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/post_entries/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/post_entries/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_entries-row', oob=oob) %}
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %}
<i class="fa fa-clock" aria-hidden="true"></i>
<div>
entries
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/post_entries/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,19 @@
{% extends '_types/post/admin/index.html' %}
{% block post_admin_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-admin-header-child', '_types/post_entries/header/_header.html') %}
{% block post_entries_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/post_entries/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/post_entries/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,2 @@
<div class="max-w-2xl mx-auto px-4 py-6">
</div>

View File

@@ -0,0 +1,5 @@
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours) }}
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours) }}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours) }}
{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours) }}

View File

@@ -0,0 +1,26 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob_.html' import root_header with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'root-settings-header-child', '_types/root/settings/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/root/settings/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/root/settings/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='cache-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,14 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
<div class="flex flex-col md:flex-row gap-3 items-start">
<form
hx-post="{{ url_for('settings.cache_clear') }}"
hx-trigger="submit"
hx-target="#cache-status"
hx-swap="innerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="border rounded px-4 py-2 bg-stone-800 text-white text-sm" type="submit">Clear cache</button>
</form>
<div id="cache-status" class="py-2"></div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'cache-header-child', '_types/root/settings/cache/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/root/settings/cache/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/root/settings/cache/_header.html' import header_row with context %}
{{ header_row() }}
<div id="cache-header-child">
{% block cache_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/root/settings/cache/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='root-settings-row', oob=oob) %}
{% call links.link(url_for('settings.home'), hx_select_search) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/root/settings/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends '_types/root/_index.html' %}
{% import 'macros/layout.html' as layout %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-settings-header-child', '_types/root/settings/header/_header.html') %}
{% block root_settings_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/root/settings/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/root/settings/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,73 @@
<div class="bg-white rounded-lg shadow">
{% if snippets %}
<div class="divide-y">
{% for s in snippets %}
<div class="flex items-center gap-4 p-4 hover:bg-stone-50 transition">
{# Name #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ s.name }}</div>
<div class="text-xs text-stone-500">
{% if s.user_id == g.user.id %}
You
{% else %}
User #{{ s.user_id }}
{% endif %}
</div>
</div>
{# Visibility badge #}
{% set badge_colours = {
'private': 'bg-stone-200 text-stone-700',
'shared': 'bg-blue-100 text-blue-700',
'admin': 'bg-amber-100 text-amber-700',
} %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ badge_colours.get(s.visibility, 'bg-stone-200 text-stone-700') }}">
{{ s.visibility }}
</span>
{# Admin: inline visibility select #}
{% if is_admin %}
<select
name="visibility"
hx-patch="{{ url_for('snippets.patch_visibility', snippet_id=s.id) }}"
hx-target="#snippets-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="text-sm border border-stone-300 rounded px-2 py-1"
>
{% for v in ['private', 'shared', 'admin'] %}
<option value="{{ v }}" {{ 'selected' if s.visibility == v else '' }}>{{ v }}</option>
{% endfor %}
</select>
{% endif %}
{# Delete button #}
{% if s.user_id == g.user.id or is_admin %}
<button
type="button"
data-confirm
data-confirm-title="Delete snippet?"
data-confirm-text="Delete &ldquo;{{ s.name }}&rdquo;?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('snippets.delete_snippet', snippet_id=s.id) }}"
hx-trigger="confirmed"
hx-target="#snippets-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0">
<i class="fa fa-trash"></i> Delete
</button>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-stone-400">
<i class="fa fa-puzzle-piece text-4xl mb-2"></i>
<p>No snippets yet. Create one from the blog editor.</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,9 @@
<div class="max-w-4xl mx-auto p-6">
<div class="mb-6 flex justify-between items-center">
<h1 class="text-3xl font-bold">Snippets</h1>
</div>
<div id="snippets-list">
{% include '_types/snippets/_list.html' %}
</div>
</div>

View File

@@ -0,0 +1,18 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'snippets-header-child', '_types/snippets/header/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/snippets/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='snippets-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/snippets/header/_header.html' import header_row with context %}
{{ header_row() }}
<div id="snippets-header-child">
{% block snippets_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/snippets/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}