Own blog domain templates, remove fragment fallbacks (Phase 6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Blog, post, home, snippets, menu_items, settings templates moved from shared to blog/templates/. Header fallbacks for cart-mini, nav-tree, auth-menu removed (fragments only). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
shared
2
shared
Submodule shared updated: 65c4989d08...322ae481ee
80
templates/_types/blog/_card.html
Normal file
80
templates/_types/blog/_card.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% 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>
|
||||
|
||||
{# Card decorations — via fragments #}
|
||||
{% if card_widgets_html %}
|
||||
{% set _card_html = card_widgets_html.get(post.id|string, "") %}
|
||||
{% if _card_html %}{{ _card_html | safe }}{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% include '_types/blog/_card/at_bar.html' %}
|
||||
|
||||
</article>
|
||||
19
templates/_types/blog/_card/at_bar.html
Normal file
19
templates/_types/blog/_card/at_bar.html
Normal 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>
|
||||
21
templates/_types/blog/_card/author.html
Normal file
21
templates/_types/blog/_card/author.html
Normal 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 %}
|
||||
32
templates/_types/blog/_card/authors.html
Normal file
32
templates/_types/blog/_card/authors.html
Normal 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 --- #}
|
||||
19
templates/_types/blog/_card/tag.html
Normal file
19
templates/_types/blog/_card/tag.html
Normal 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 %}
|
||||
22
templates/_types/blog/_card/tag_group.html
Normal file
22
templates/_types/blog/_card/tag_group.html
Normal 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 %}
|
||||
17
templates/_types/blog/_card/tags.html
Normal file
17
templates/_types/blog/_card/tags.html
Normal 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 --- #}
|
||||
59
templates/_types/blog/_card_tile.html
Normal file
59
templates/_types/blog/_card_tile.html
Normal 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>
|
||||
111
templates/_types/blog/_cards.html
Normal file
111
templates/_types/blog/_cards.html
Normal 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 %}
|
||||
|
||||
40
templates/_types/blog/_oob_elements.html
Normal file
40
templates/_types/blog/_oob_elements.html
Normal 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 %}
|
||||
9
templates/_types/blog/admin/tag_groups/_edit_header.html
Normal file
9
templates/_types/blog/admin/tag_groups/_edit_header.html
Normal 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 %}
|
||||
79
templates/_types/blog/admin/tag_groups/_edit_main_panel.html
Normal file
79
templates/_types/blog/admin/tag_groups/_edit_main_panel.html
Normal 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>
|
||||
17
templates/_types/blog/admin/tag_groups/_edit_oob.html
Normal file
17
templates/_types/blog/admin/tag_groups/_edit_oob.html
Normal 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 %}
|
||||
9
templates/_types/blog/admin/tag_groups/_header.html
Normal file
9
templates/_types/blog/admin/tag_groups/_header.html
Normal 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 %}
|
||||
73
templates/_types/blog/admin/tag_groups/_main_panel.html
Normal file
73
templates/_types/blog/admin/tag_groups/_main_panel.html
Normal 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>
|
||||
16
templates/_types/blog/admin/tag_groups/_oob_elements.html
Normal file
16
templates/_types/blog/admin/tag_groups/_oob_elements.html
Normal 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 %}
|
||||
13
templates/_types/blog/admin/tag_groups/edit.html
Normal file
13
templates/_types/blog/admin/tag_groups/edit.html
Normal 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 %}
|
||||
20
templates/_types/blog/admin/tag_groups/index.html
Normal file
20
templates/_types/blog/admin/tag_groups/index.html
Normal 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 %}
|
||||
19
templates/_types/blog/desktop/menu.html
Normal file
19
templates/_types/blog/desktop/menu.html
Normal 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>
|
||||
|
||||
|
||||
62
templates/_types/blog/desktop/menu/authors.html
Normal file
62
templates/_types/blog/desktop/menu/authors.html
Normal 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>
|
||||
|
||||
70
templates/_types/blog/desktop/menu/tag_groups.html
Normal file
70
templates/_types/blog/desktop/menu/tag_groups.html
Normal 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>
|
||||
59
templates/_types/blog/desktop/menu/tags.html
Normal file
59
templates/_types/blog/desktop/menu/tags.html
Normal 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>
|
||||
|
||||
7
templates/_types/blog/header/_header.html
Normal file
7
templates/_types/blog/header/_header.html
Normal 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 %}
|
||||
37
templates/_types/blog/index.html
Normal file
37
templates/_types/blog/index.html
Normal 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 %}
|
||||
13
templates/_types/blog/mobile/_filter/_hamburger.html
Normal file
13
templates/_types/blog/mobile/_filter/_hamburger.html
Normal 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>
|
||||
14
templates/_types/blog/mobile/_filter/summary.html
Normal file
14
templates/_types/blog/mobile/_filter/summary.html
Normal 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 %}
|
||||
|
||||
31
templates/_types/blog/mobile/_filter/summary/authors.html
Normal file
31
templates/_types/blog/mobile/_filter/summary/authors.html
Normal 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 %}
|
||||
33
templates/_types/blog/mobile/_filter/summary/tag_groups.html
Normal file
33
templates/_types/blog/mobile/_filter/summary/tag_groups.html
Normal 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 %}
|
||||
31
templates/_types/blog/mobile/_filter/summary/tags.html
Normal file
31
templates/_types/blog/mobile/_filter/summary/tags.html
Normal 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 %}
|
||||
22
templates/_types/blog/not_found.html
Normal file
22
templates/_types/blog/not_found.html
Normal 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.index')|host }}"
|
||||
hx-get="{{ url_for('blog.index')|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 %}
|
||||
55
templates/_types/blog_drafts/_main_panel.html
Normal file
55
templates/_types/blog_drafts/_main_panel.html
Normal 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>
|
||||
12
templates/_types/blog_drafts/_oob_elements.html
Normal file
12
templates/_types/blog_drafts/_oob_elements.html
Normal 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 %}
|
||||
11
templates/_types/blog_drafts/index.html
Normal file
11
templates/_types/blog_drafts/index.html
Normal 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 %}
|
||||
259
templates/_types/blog_new/_main_panel.html
Normal file
259
templates/_types/blog_new/_main_panel.html
Normal 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="{{ 'Page title...' if is_page else '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 Page' if is_page else '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>
|
||||
12
templates/_types/blog_new/_oob_elements.html
Normal file
12
templates/_types/blog_new/_oob_elements.html
Normal 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 %}
|
||||
11
templates/_types/blog_new/index.html
Normal file
11
templates/_types/blog_new/index.html
Normal 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 %}
|
||||
20
templates/_types/browse/like/button.html
Normal file
20
templates/_types/browse/like/button.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<button
|
||||
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
|
||||
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-swap-settle="0ms"
|
||||
{% if liked %}
|
||||
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
|
||||
{% else %}
|
||||
aria-label="Like this {{ item_type if item_type else 'product' }}"
|
||||
{% endif %}
|
||||
>
|
||||
{% if liked %}
|
||||
<i aria-hidden="true" class="fa-solid fa-heart"></i>
|
||||
{% else %}
|
||||
<i aria-hidden="true" class="fa-regular fa-heart"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
19
templates/_types/home/_oob_elements.html
Normal file
19
templates/_types/home/_oob_elements.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block content %}
|
||||
<article class="relative">
|
||||
<div class="blog-content p-2">
|
||||
{% if post.html %}
|
||||
{{post.html|safe}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
14
templates/_types/home/index.html
Normal file
14
templates/_types/home/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
{% block meta %}
|
||||
{% include '_types/post/_meta.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="relative">
|
||||
<div class="blog-content p-2">
|
||||
{% if post.html %}
|
||||
{{post.html|safe}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
125
templates/_types/menu_items/_form.html
Normal file
125
templates/_types/menu_items/_form.html
Normal 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.container_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.feature_image %}
|
||||
<img src="{{ menu_item.feature_image }}"
|
||||
alt="{{ menu_item.label }}"
|
||||
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.label }}</div>
|
||||
<div class="text-xs text-stone-500">{{ menu_item.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>
|
||||
68
templates/_types/menu_items/_list.html
Normal file
68
templates/_types/menu_items/_list.html
Normal 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.feature_image %}
|
||||
<img src="{{ item.feature_image }}"
|
||||
alt="{{ item.label }}"
|
||||
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.label }}</div>
|
||||
<div class="text-xs text-stone-500 truncate">{{ item.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.label }} 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>
|
||||
20
templates/_types/menu_items/_main_panel.html
Normal file
20
templates/_types/menu_items/_main_panel.html
Normal 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>
|
||||
31
templates/_types/menu_items/_nav_oob.html
Normal file
31
templates/_types/menu_items/_nav_oob.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% set _app_slugs = {'cart': cart_url('/')} %}
|
||||
{% set _first_seg = request.path.strip('/').split('/')[0] %}
|
||||
<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.slug, blog_url('/' + item.slug + '/')) %}
|
||||
<a
|
||||
href="{{ _href }}"
|
||||
{% if item.slug not in _app_slugs %}
|
||||
hx-get="/{{ item.slug }}/"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
{% endif %}
|
||||
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
|
||||
class="{{styles.nav_button}}"
|
||||
>
|
||||
{% if item.feature_image %}
|
||||
<img src="{{ item.feature_image }}"
|
||||
alt="{{ item.label }}"
|
||||
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.label }}</span>
|
||||
</a>
|
||||
{% endcall %}
|
||||
</div>
|
||||
23
templates/_types/menu_items/_oob_elements.html
Normal file
23
templates/_types/menu_items/_oob_elements.html
Normal 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 %}
|
||||
|
||||
44
templates/_types/menu_items/_page_search_results.html
Normal file
44
templates/_types/menu_items/_page_search_results.html
Normal 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 %}
|
||||
9
templates/_types/menu_items/header/_header.html
Normal file
9
templates/_types/menu_items/header/_header.html
Normal 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 %}
|
||||
20
templates/_types/menu_items/index.html
Normal file
20
templates/_types/menu_items/index.html
Normal 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 %}
|
||||
24
templates/_types/post/_entry_container.html
Normal file
24
templates/_types/post/_entry_container.html
Normal 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>
|
||||
38
templates/_types/post/_entry_items.html
Normal file
38
templates/_types/post/_entry_items.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{# 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 %}
|
||||
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}"
|
||||
>
|
||||
{% if post.feature_image %}
|
||||
<img src="{{ post.feature_image }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Load more entries one at a time until container is full #}
|
||||
{% if has_more_entries %}
|
||||
<div id="entries-load-sentinel-{{ current_page }}"
|
||||
hx-get="{{ url_for('blog.post.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 %}
|
||||
65
templates/_types/post/_main_panel.html
Normal file
65
templates/_types/post/_main_panel.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{# Main panel fragment for HTMX navigation - post/page article content #}
|
||||
<article class="relative">
|
||||
{# Draft indicator + edit link (shown for both posts and pages) #}
|
||||
{% 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 not post.is_page %}
|
||||
{# ── Blog post chrome: like button, excerpt, tags/authors ── #}
|
||||
{% 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 %}
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
36
templates/_types/post/_oob_elements.html
Normal file
36
templates/_types/post/_oob_elements.html
Normal 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 %}
|
||||
50
templates/_types/post/admin/_associated_entries.html
Normal file
50
templates/_types/post/admin/_associated_entries.html
Normal 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>
|
||||
88
templates/_types/post/admin/_calendar_view.html
Normal file
88
templates/_types/post/admin/_calendar_view.html
Normal 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">«</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">‹</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">›</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">»</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 %}
|
||||
{% 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>
|
||||
7
templates/_types/post/admin/_main_panel.html
Normal file
7
templates/_types/post/admin/_main_panel.html
Normal 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>
|
||||
50
templates/_types/post/admin/_nav_entries.html
Normal file
50
templates/_types/post/admin/_nav_entries.html
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
{# 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>
|
||||
|
||||
{# Widget-driven nav items container #}
|
||||
<div id="associated-items-container"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
style="scroll-behavior: smooth;"
|
||||
_="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">
|
||||
{% for wdata in container_nav_widgets %}
|
||||
{% with ctx=wdata.ctx %}
|
||||
{% include wdata.widget.template with context %}
|
||||
{% endwith %}
|
||||
{% 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>
|
||||
80
templates/_types/post/admin/_nav_entries_oob.html
Normal file
80
templates/_types/post/admin/_nav_entries_oob.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{# 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">
|
||||
{# 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>
|
||||
|
||||
<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
|
||||
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">
|
||||
{# Calendar entries #}
|
||||
{% if associated_entries and associated_entries.entries %}
|
||||
{% for entry in associated_entries.entries %}
|
||||
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
<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 %}
|
||||
{% endif %}
|
||||
{# Calendar links #}
|
||||
{% if calendars %}
|
||||
{% for calendar in calendars %}
|
||||
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
|
||||
<a
|
||||
href="{{ local_href }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>{{calendar.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</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>
|
||||
</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 %}
|
||||
13
templates/_types/post/admin/header/_header.html
Normal file
13
templates/_types/post/admin/header/_header.html
Normal 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 %}
|
||||
25
templates/_types/post/index.html
Normal file
25
templates/_types/post/index.html
Normal 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 %}
|
||||
137
templates/_types/post_data/_main_panel.html
Normal file
137
templates/_types/post_data/_main_panel.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% macro render_scalar_table(obj) -%}
|
||||
<div class="w-full overflow-x-auto sm:overflow-visible">
|
||||
<table class="w-full table-fixed text-sm border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
|
||||
<thead class="bg-neutral-50/70 dark:bg-neutral-900/60">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for col in obj.__mapper__.columns %}
|
||||
{% set key = col.key %}
|
||||
{% set val = obj|attr(key) %}
|
||||
{% if key != "_sa_instance_state" %}
|
||||
<tr class="border-t border-neutral-200 dark:border-neutral-800 align-top">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-neutral-600 dark:text-neutral-400 align-top">{{ key }}</td>
|
||||
<td class="px-3 py-2 align-top">
|
||||
{% if val is none %}
|
||||
<span class="text-neutral-400">—</span>
|
||||
{% elif val.__class__.__name__ in ["datetime", "date"] and val.isoformat is defined %}
|
||||
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ val.isoformat() }}</code></pre>
|
||||
{% elif val is string %}
|
||||
<pre class="whitespace-pre-wrap break-words break-all text-xs">{{ val }}</pre>
|
||||
{% else %}
|
||||
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ val }}</code></pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro render_model(obj, depth=0, max_depth=2) -%}
|
||||
{% if obj is none %}
|
||||
<span class="text-neutral-400">—</span>
|
||||
{% else %}
|
||||
<div class="space-y-4">
|
||||
{{ render_scalar_table(obj) }}
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for rel in obj.__mapper__.relationships %}
|
||||
{% set rel_name = rel.key %}
|
||||
{% set loaded = rel.key in obj.__dict__ %}
|
||||
{% if loaded %}
|
||||
{% set value = obj|attr(rel_name) %}
|
||||
{% else %}
|
||||
{% set value = none %}
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-xl border border-neutral-200 dark:border-neutral-800">
|
||||
<div class="px-3 py-2 bg-neutral-50/70 dark:bg-neutral-900/60 text-sm font-medium">
|
||||
Relationship: <span class="font-semibold">{{ rel_name }}</span>
|
||||
<span class="ml-2 text-xs text-neutral-500">
|
||||
{{ 'many' if rel.uselist else 'one' }} → {{ rel.mapper.class_.__name__ }}
|
||||
{% if not loaded %} • <em>not loaded</em>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-3 text-sm">
|
||||
{% if value is none %}
|
||||
<span class="text-neutral-400">—</span>
|
||||
|
||||
{% elif rel.uselist %}
|
||||
{% set items = value or [] %}
|
||||
<div class="text-neutral-500 mb-2">{{ items|length }} item{{ '' if items|length == 1 else 's' }}</div>
|
||||
|
||||
{% if items %}
|
||||
<div class="w-full overflow-x-auto sm:overflow-visible">
|
||||
<table class="w-full table-fixed text-sm border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
|
||||
<thead class="bg-neutral-50/70 dark:bg-neutral-900/60">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left w-10">#</th>
|
||||
<th class="px-2 py-1 text-left">Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr class="border-t border-neutral-200 dark:border-neutral-800 align-top">
|
||||
<td class="px-2 py-1 whitespace-nowrap align-top">{{ loop.index }}</td>
|
||||
<td class="px-2 py-1 align-top">
|
||||
{% set ident = [] %}
|
||||
{% for k in ['id','ghost_id','uuid','slug','name','title'] if k in it.__mapper__.c %}
|
||||
{% set v = (it|attr(k))|default('', true) %}
|
||||
{% do ident.append(k ~ '=' ~ v) %}
|
||||
{% endfor %}
|
||||
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ (ident|join(' • ')) or it|string }}</code></pre>
|
||||
|
||||
{% if depth < max_depth %}
|
||||
<div class="mt-2 pl-3 border-l border-neutral-200 dark:border-neutral-800">
|
||||
{{ render_model(it, depth+1, max_depth) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-1 text-xs text-neutral-500">…max depth reached…</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{% set child = value %}
|
||||
{% set ident = [] %}
|
||||
{% for k in ['id','ghost_id','uuid','slug','name','title'] if k in child.__mapper__.c %}
|
||||
{% set v = (child|attr(k))|default('', true) %}
|
||||
{% do ident.append(k ~ '=' ~ v) %}
|
||||
{% endfor %}
|
||||
<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{{ (ident|join(' • ')) or child|string }}</code></pre>
|
||||
|
||||
{% if depth < max_depth %}
|
||||
<div class="pl-3 border-l border-neutral-200 dark:border-neutral-800">
|
||||
{{ render_model(child, depth+1, max_depth) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-xs text-neutral-500">…max depth reached…</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
<div class="px-4 py-8">
|
||||
<div class="mb-6 text-sm text-neutral-500">
|
||||
Model: <code>Post</code> • Table: <code>{{ original_post.__tablename__ }}</code>
|
||||
</div>
|
||||
{{ render_model(original_post, 0, 2) }}
|
||||
</div>
|
||||
|
||||
2
templates/_types/post_data/_nav.html
Normal file
2
templates/_types/post_data/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
28
templates/_types/post_data/_oob_elements.html
Normal file
28
templates/_types/post_data/_oob_elements.html
Normal 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_data-header-child', '_types/post_data/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_data/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/post_data/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
15
templates/_types/post_data/header/_header.html
Normal file
15
templates/_types/post_data/header/_header.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_data-row', oob=oob) %}
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||
<i class="fa fa-database" aria-hidden="true"></i>
|
||||
<div>data</div>
|
||||
</a>
|
||||
{% call links.desktop_nav() %}
|
||||
{#% include '_types/post_data/_nav.html' %#}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
24
templates/_types/post_data/index.html
Normal file
24
templates/_types/post_data/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends '_types/post/admin/index.html' %}
|
||||
|
||||
{% block ___app_title %}
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.menu_row() %}
|
||||
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-database" aria-hidden="true"></i>
|
||||
<div>
|
||||
data
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/post_data/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/post_data/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post_data/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
352
templates/_types/post_edit/_main_panel.html
Normal file
352
templates/_types/post_edit/_main_panel.html
Normal file
@@ -0,0 +1,352 @@
|
||||
{# ── 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-edit-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="updated_at" value="{{ ghost_post.updated_at if ghost_post else '' }}">
|
||||
<input type="hidden" id="lexical-json-input" name="lexical" value="">
|
||||
<input type="hidden" id="feature-image-input" name="feature_image" value="{{ ghost_post.feature_image or '' if ghost_post else '' }}">
|
||||
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{{ ghost_post.feature_image_caption or '' if ghost_post else '' }}">
|
||||
|
||||
{# ── Feature image ── #}
|
||||
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
|
||||
{# Empty state: add link #}
|
||||
<div
|
||||
id="feature-image-empty"
|
||||
class="{{ 'hidden' if ghost_post and ghost_post.feature_image else '' }}"
|
||||
>
|
||||
<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 {{ '' if ghost_post and ghost_post.feature_image else 'hidden' }}"
|
||||
>
|
||||
<img
|
||||
id="feature-image-preview"
|
||||
src="{{ ghost_post.feature_image or '' if ghost_post else '' }}"
|
||||
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="{{ ghost_post.feature_image_caption or '' if ghost_post else '' }}"
|
||||
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="{{ ghost_post.title if ghost_post else '' }}"
|
||||
placeholder="{{ 'Page title...' if post and post.is_page else '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"
|
||||
>{{ ghost_post.custom_excerpt or '' if ghost_post else '' }}</textarea>
|
||||
|
||||
{# ── Editor mount point ── #}
|
||||
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
|
||||
|
||||
{# ── Initial Lexical JSON from Ghost ── #}
|
||||
<script id="lexical-initial-data" type="application/json">
|
||||
{{ (ghost_post.lexical or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}')|safe }}
|
||||
</script>
|
||||
|
||||
{# ── Status + Publish mode + Save footer ── #}
|
||||
{% set already_emailed = ghost_post and ghost_post.email and ghost_post.email.status %}
|
||||
<div class="flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
|
||||
<select
|
||||
id="status-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' if not ghost_post or ghost_post.status == 'draft' else '' }}>Draft</option>
|
||||
<option value="published" {{ 'selected' if ghost_post and ghost_post.status == 'published' else '' }}>Published</option>
|
||||
</select>
|
||||
|
||||
{# Publish mode — only relevant when publishing #}
|
||||
<select
|
||||
id="publish-mode-select"
|
||||
name="publish_mode"
|
||||
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600
|
||||
{{ 'hidden' if not ghost_post or ghost_post.status != 'published' else '' }}
|
||||
{{ 'opacity-50 pointer-events-none' if already_emailed else '' }}"
|
||||
{{ 'disabled' if already_emailed else '' }}
|
||||
>
|
||||
<option value="web" selected>Web only</option>
|
||||
<option value="email">Email only</option>
|
||||
<option value="both">Web + Email</option>
|
||||
</select>
|
||||
|
||||
{# Newsletter picker — only when email is involved #}
|
||||
<select
|
||||
id="newsletter-select"
|
||||
name="newsletter_slug"
|
||||
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600 hidden"
|
||||
{{ 'disabled' if already_emailed else '' }}
|
||||
>
|
||||
<option value="">Select newsletter…</option>
|
||||
{% for nl in newsletters|default([]) %}
|
||||
<option value="{{ nl.slug }}">{{ nl.name }}</option>
|
||||
{% endfor %}
|
||||
</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"
|
||||
>Save</button>
|
||||
|
||||
{% if save_success %}
|
||||
<span class="text-[14px] text-green-600">Saved.</span>
|
||||
{% endif %}
|
||||
{% if request.args.get('publish_requested') %}
|
||||
<span class="text-[14px] text-blue-600">Publish requested — an admin will review.</span>
|
||||
{% endif %}
|
||||
{% if post and 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 %}
|
||||
{% if already_emailed %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
|
||||
Emailed{% if ghost_post.newsletter %} to {{ ghost_post.newsletter.name }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Publish-mode show/hide logic ── #}
|
||||
<script>
|
||||
(function() {
|
||||
var statusSel = document.getElementById('status-select');
|
||||
var modeSel = document.getElementById('publish-mode-select');
|
||||
var nlSel = document.getElementById('newsletter-select');
|
||||
var alreadyEmailed = {{ 'true' if already_emailed else 'false' }};
|
||||
|
||||
function sync() {
|
||||
var isPublished = statusSel.value === 'published';
|
||||
// Show publish mode only when status is published and not already emailed
|
||||
if (isPublished && !alreadyEmailed) {
|
||||
modeSel.classList.remove('hidden');
|
||||
} else {
|
||||
modeSel.classList.add('hidden');
|
||||
}
|
||||
// Show newsletter picker when email is involved
|
||||
var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');
|
||||
if (needsEmail) {
|
||||
nlSel.classList.remove('hidden');
|
||||
} else {
|
||||
nlSel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
statusSel.addEventListener('change', sync);
|
||||
modeSel.addEventListener('change', sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
</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, then shrink only the editor area.
|
||||
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 ── */
|
||||
var dataEl = document.getElementById('lexical-initial-data');
|
||||
var initialJson = dataEl ? dataEl.textContent.trim() : null;
|
||||
if (initialJson) {
|
||||
var hidden = document.getElementById('lexical-json-input');
|
||||
if (hidden) hidden.value = initialJson;
|
||||
}
|
||||
window.mountEditor('lexical-editor', {
|
||||
initialJson: initialJson,
|
||||
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-edit-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>
|
||||
5
templates/_types/post_edit/_nav.html
Normal file
5
templates/_types/post_edit/_nav.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
settings
|
||||
{% endcall %}
|
||||
19
templates/_types/post_edit/_oob_elements.html
Normal file
19
templates/_types/post_edit/_oob_elements.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{% 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_edit-header-child', '_types/post_edit/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_edit/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post_edit/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
14
templates/_types/post_edit/header/_header.html
Normal file
14
templates/_types/post_edit/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_edit-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
|
||||
<div>
|
||||
edit
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/post_edit/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
17
templates/_types/post_edit/index.html
Normal file
17
templates/_types/post_edit/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% 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_edit/header/_header.html') %}
|
||||
{% block post_edit_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/post_edit/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post_edit/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
48
templates/_types/post_entries/_main_panel.html
Normal file
48
templates/_types/post_entries/_main_panel.html
Normal 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>
|
||||
2
templates/_types/post_entries/_nav.html
Normal file
2
templates/_types/post_entries/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
28
templates/_types/post_entries/_oob_elements.html
Normal file
28
templates/_types/post_entries/_oob_elements.html
Normal 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 %}
|
||||
|
||||
|
||||
17
templates/_types/post_entries/header/_header.html
Normal file
17
templates/_types/post_entries/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
19
templates/_types/post_entries/index.html
Normal file
19
templates/_types/post_entries/index.html
Normal 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 %}
|
||||
198
templates/_types/post_settings/_main_panel.html
Normal file
198
templates/_types/post_settings/_main_panel.html
Normal file
@@ -0,0 +1,198 @@
|
||||
{# ── Post/Page Settings Form ── #}
|
||||
{% set gp = ghost_post or {} %}
|
||||
{% set _is_page = post.is_page if post else False %}
|
||||
|
||||
{% macro field_label(text, field_for=None) %}
|
||||
<label {% if field_for %}for="{{ field_for }}"{% endif %}
|
||||
class="block text-[13px] font-medium text-stone-500 mb-[4px]">{{ text }}</label>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro text_input(name, value='', placeholder='', type='text', maxlength=None) %}
|
||||
<input
|
||||
type="{{ type }}"
|
||||
name="{{ name }}"
|
||||
id="settings-{{ name }}"
|
||||
value="{{ value }}"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
|
||||
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||
bg-white text-stone-700 placeholder:text-stone-300
|
||||
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||
>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro textarea_input(name, value='', placeholder='', rows=3, maxlength=None) %}
|
||||
<textarea
|
||||
name="{{ name }}"
|
||||
id="settings-{{ name }}"
|
||||
rows="{{ rows }}"
|
||||
placeholder="{{ placeholder }}"
|
||||
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
|
||||
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||
bg-white text-stone-700 placeholder:text-stone-300 resize-y
|
||||
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||
>{{ value }}</textarea>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro checkbox_input(name, checked=False, label='') %}
|
||||
<label class="inline-flex items-center gap-[8px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="{{ name }}"
|
||||
id="settings-{{ name }}"
|
||||
{{ 'checked' if checked else '' }}
|
||||
class="rounded border-stone-300 text-stone-600 focus:ring-stone-300"
|
||||
>
|
||||
<span class="text-[14px] text-stone-600">{{ label }}</span>
|
||||
</label>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro section(title, open=False) %}
|
||||
<details class="border border-stone-200 rounded-[8px] overflow-hidden" {{ 'open' if open else '' }}>
|
||||
<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer
|
||||
select-none hover:bg-stone-100 transition-colors">
|
||||
{{ title }}
|
||||
</summary>
|
||||
<div class="px-[16px] py-[12px] space-y-[12px]">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</details>
|
||||
{% endmacro %}
|
||||
|
||||
<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="updated_at" value="{{ gp.updated_at or '' }}">
|
||||
|
||||
<div class="space-y-[12px] mt-[16px]">
|
||||
|
||||
{# ── General ── #}
|
||||
{% call section('General', open=True) %}
|
||||
<div>
|
||||
{{ field_label('Slug', 'settings-slug') }}
|
||||
{{ text_input('slug', gp.slug or '', 'page-slug' if _is_page else 'post-slug') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('Published at', 'settings-published_at') }}
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="published_at"
|
||||
id="settings-published_at"
|
||||
value="{{ gp.published_at[:16] if gp.published_at else '' }}"
|
||||
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||
bg-white text-stone-700
|
||||
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
{{ checkbox_input('featured', gp.featured, 'Featured page' if _is_page else 'Featured post') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('Visibility', 'settings-visibility') }}
|
||||
<select
|
||||
name="visibility"
|
||||
id="settings-visibility"
|
||||
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||
bg-white text-stone-700
|
||||
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||
>
|
||||
<option value="public" {{ 'selected' if (gp.visibility or 'public') == 'public' }}>Public</option>
|
||||
<option value="members" {{ 'selected' if gp.visibility == 'members' }}>Members</option>
|
||||
<option value="paid" {{ 'selected' if gp.visibility == 'paid' }}>Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
{{ checkbox_input('email_only', gp.email_only, 'Email only') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{# ── Tags ── #}
|
||||
{% call section('Tags') %}
|
||||
<div>
|
||||
{{ field_label('Tags (comma-separated)', 'settings-tags') }}
|
||||
{% set tag_names = gp.tags|map(attribute='name')|list|join(', ') if gp.tags else '' %}
|
||||
{{ text_input('tags', tag_names, 'news, updates, featured') }}
|
||||
<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{# ── Feature Image ── #}
|
||||
{% call section('Feature Image') %}
|
||||
<div>
|
||||
{{ field_label('Alt text', 'settings-feature_image_alt') }}
|
||||
{{ text_input('feature_image_alt', gp.feature_image_alt or '', 'Describe the feature image') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{# ── SEO / Meta ── #}
|
||||
{% call section('SEO / Meta') %}
|
||||
<div>
|
||||
{{ field_label('Meta title', 'settings-meta_title') }}
|
||||
{{ text_input('meta_title', gp.meta_title or '', 'SEO title', maxlength=300) }}
|
||||
<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p>
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('Meta description', 'settings-meta_description') }}
|
||||
{{ textarea_input('meta_description', gp.meta_description or '', 'SEO description', rows=2, maxlength=500) }}
|
||||
<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p>
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('Canonical URL', 'settings-canonical_url') }}
|
||||
{{ text_input('canonical_url', gp.canonical_url or '', 'https://example.com/original-post', type='url') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{# ── Facebook / OpenGraph ── #}
|
||||
{% call section('Facebook / OpenGraph') %}
|
||||
<div>
|
||||
{{ field_label('OG title', 'settings-og_title') }}
|
||||
{{ text_input('og_title', gp.og_title or '') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('OG description', 'settings-og_description') }}
|
||||
{{ textarea_input('og_description', gp.og_description or '', rows=2) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('OG image URL', 'settings-og_image') }}
|
||||
{{ text_input('og_image', gp.og_image or '', 'https://...', type='url') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{# ── X / Twitter ── #}
|
||||
{% call section('X / Twitter') %}
|
||||
<div>
|
||||
{{ field_label('Twitter title', 'settings-twitter_title') }}
|
||||
{{ text_input('twitter_title', gp.twitter_title or '') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('Twitter description', 'settings-twitter_description') }}
|
||||
{{ textarea_input('twitter_description', gp.twitter_description or '', rows=2) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ field_label('Twitter image URL', 'settings-twitter_image') }}
|
||||
{{ text_input('twitter_image', gp.twitter_image or '', 'https://...', type='url') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{# ── Advanced ── #}
|
||||
{% call section('Advanced') %}
|
||||
<div>
|
||||
{{ field_label('Custom template', 'settings-custom_template') }}
|
||||
{{ text_input('custom_template', gp.custom_template or '', 'custom-page.hbs' if _is_page else 'custom-post.hbs') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Save footer ── #}
|
||||
<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">
|
||||
<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"
|
||||
>Save settings</button>
|
||||
|
||||
{% if save_success %}
|
||||
<span class="text-[14px] text-green-600">Saved.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
5
templates/_types/post_settings/_nav.html
Normal file
5
templates/_types/post_settings/_nav.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
|
||||
edit
|
||||
{% endcall %}
|
||||
19
templates/_types/post_settings/_oob_elements.html
Normal file
19
templates/_types/post_settings/_oob_elements.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{% 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_settings-header-child', '_types/post_settings/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_settings/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post_settings/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
14
templates/_types/post_settings/header/_header.html
Normal file
14
templates/_types/post_settings/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_settings-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
<div>
|
||||
settings
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/post_settings/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
17
templates/_types/post_settings/index.html
Normal file
17
templates/_types/post_settings/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% 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_settings/header/_header.html') %}
|
||||
{% block post_settings_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/post_settings/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post_settings/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -11,9 +11,6 @@
|
||||
{# Cart mini — fetched from cart app as fragment #}
|
||||
{% if cart_mini_html %}
|
||||
{{ cart_mini_html | safe }}
|
||||
{% else %}
|
||||
{% from '_types/cart/_mini.html' import mini with context %}
|
||||
{{mini()}}
|
||||
{% endif %}
|
||||
|
||||
{# Site title #}
|
||||
@@ -26,18 +23,10 @@
|
||||
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
|
||||
{% if nav_tree_html %}
|
||||
{{ nav_tree_html | safe }}
|
||||
{% else %}
|
||||
{% include '_types/root/_nav.html' %}
|
||||
{% endif %}
|
||||
{# Auth menu — fetched from account app as fragment #}
|
||||
{% if auth_menu_html %}
|
||||
{{ auth_menu_html | safe }}
|
||||
{% else %}
|
||||
{% if not g.user %}
|
||||
{% include '_types/root/_sign_in.html' %}
|
||||
{% else %}
|
||||
{% include '_types/root/_full_user.html' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% include "_types/root/_nav_panel.html"%}
|
||||
</nav>
|
||||
@@ -48,12 +37,6 @@
|
||||
<div class="block md:hidden">
|
||||
{% if auth_menu_html %}
|
||||
{{ auth_menu_html | safe }}
|
||||
{% else %}
|
||||
{% if g.user %}
|
||||
{% include '_types/root/mobile/_full_user.html' %}
|
||||
{% else %}
|
||||
{% include '_types/root/mobile/_sign_in.html' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
2
templates/_types/root/settings/_main_panel.html
Normal file
2
templates/_types/root/settings/_main_panel.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<div class="max-w-2xl mx-auto px-4 py-6">
|
||||
</div>
|
||||
5
templates/_types/root/settings/_nav.html
Normal file
5
templates/_types/root/settings/_nav.html
Normal 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) }}
|
||||
26
templates/_types/root/settings/_oob_elements.html
Normal file
26
templates/_types/root/settings/_oob_elements.html
Normal 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 %}
|
||||
|
||||
9
templates/_types/root/settings/cache/_header.html
vendored
Normal file
9
templates/_types/root/settings/cache/_header.html
vendored
Normal 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 %}
|
||||
14
templates/_types/root/settings/cache/_main_panel.html
vendored
Normal file
14
templates/_types/root/settings/cache/_main_panel.html
vendored
Normal 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>
|
||||
16
templates/_types/root/settings/cache/_oob_elements.html
vendored
Normal file
16
templates/_types/root/settings/cache/_oob_elements.html
vendored
Normal 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 %}
|
||||
20
templates/_types/root/settings/cache/index.html
vendored
Normal file
20
templates/_types/root/settings/cache/index.html
vendored
Normal 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 %}
|
||||
11
templates/_types/root/settings/header/_header.html
Normal file
11
templates/_types/root/settings/header/_header.html
Normal 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 %}
|
||||
18
templates/_types/root/settings/index.html
Normal file
18
templates/_types/root/settings/index.html
Normal 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 %}
|
||||
73
templates/_types/snippets/_list.html
Normal file
73
templates/_types/snippets/_list.html
Normal 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 “{{ s.name }}”?"
|
||||
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>
|
||||
9
templates/_types/snippets/_main_panel.html
Normal file
9
templates/_types/snippets/_main_panel.html
Normal 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>
|
||||
18
templates/_types/snippets/_oob_elements.html
Normal file
18
templates/_types/snippets/_oob_elements.html
Normal 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 %}
|
||||
9
templates/_types/snippets/header/_header.html
Normal file
9
templates/_types/snippets/header/_header.html
Normal 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 %}
|
||||
20
templates/_types/snippets/index.html
Normal file
20
templates/_types/snippets/index.html
Normal 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 %}
|
||||
21
templates/macros/admin_nav.html
Normal file
21
templates/macros/admin_nav.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{#
|
||||
Shared admin navigation macro
|
||||
Use this instead of duplicate _nav.html files
|
||||
#}
|
||||
|
||||
{% macro admin_nav_item(href, icon='cog', label='', select_colours='', aclass=styles.nav_button) %}
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(href, hx_select_search, select_colours, True, aclass=aclass) %}
|
||||
<i class="fa fa-{{ icon }}" aria-hidden="true"></i>
|
||||
{{ label }}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro placeholder_nav() %}
|
||||
{# Placeholder for admin sections without specific nav items #}
|
||||
<div class="relative nav-group">
|
||||
<span class="block px-3 py-2 text-stone-400 text-sm italic">
|
||||
Admin options
|
||||
</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
68
templates/macros/scrolling_menu.html
Normal file
68
templates/macros/scrolling_menu.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{#
|
||||
Scrolling menu macro with arrow navigation
|
||||
|
||||
Creates a horizontally scrollable menu (desktop) or vertically scrollable (mobile)
|
||||
with arrow buttons that appear/hide based on content overflow.
|
||||
|
||||
Parameters:
|
||||
- container_id: Unique ID for the scroll container
|
||||
- items: List of items to iterate over
|
||||
- item_content: Caller block that renders each item (receives 'item' variable)
|
||||
- wrapper_class: Optional additional classes for outer wrapper
|
||||
- container_class: Optional additional classes for scroll container
|
||||
- item_class: Optional additional classes for each item wrapper
|
||||
#}
|
||||
|
||||
{% macro scrolling_menu(container_id, items, wrapper_class='', container_class='', item_class='') %}
|
||||
{% if items %}
|
||||
{# Left scroll arrow - desktop only #}
|
||||
<button
|
||||
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll left"
|
||||
_="on click
|
||||
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
{# Scrollable container #}
|
||||
<div id="{{ container_id }}"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
|
||||
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 .scrolling-menu-arrow-{{ container_id }}
|
||||
add .flex to .scrolling-menu-arrow-{{ container_id }}
|
||||
else
|
||||
add .hidden to .scrolling-menu-arrow-{{ container_id }}
|
||||
remove .flex from .scrolling-menu-arrow-{{ container_id }}
|
||||
end">
|
||||
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
|
||||
{% for item in items %}
|
||||
<div class="{{ item_class }}">
|
||||
{{ caller(item) }}
|
||||
</div>
|
||||
{% 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="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll right"
|
||||
_="on click
|
||||
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
24
templates/macros/stickers.html
Normal file
24
templates/macros/stickers.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% macro sticker(src, title, enabled, size=40, found=false) -%}
|
||||
|
||||
<span class="relative inline-flex items-center justify-center group"
|
||||
tabindex="0" aria-label="{{ title|capitalize }}">
|
||||
<!-- sticker icon -->
|
||||
<img
|
||||
src="{{ src }}"
|
||||
width="{{size}}" height="{{size}}"
|
||||
alt="{{ title|capitalize }}"
|
||||
title="{{ title|capitalize }}"
|
||||
class="{% if found %}border-2 border-yellow-200 bg-yellow-300{% endif %} {%if enabled %} opacity-100 {% else %} opacity-40 saturate-0 {% endif %}"
|
||||
/>
|
||||
|
||||
<!-- tooltip -->
|
||||
<span role="tooltip"
|
||||
class="pointer-events-none absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover/tt:block group-focus-visible/tt:block whitespace-nowrap rounded-md bg-stone-900 text-white text-xs px-2 py-1 shadow-lg">
|
||||
{{ title|capitalize if title|lower != 'sugarfree' else 'Sugar' }}
|
||||
<!-- little arrow -->
|
||||
<span class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-stone-900"></span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{%- endmacro -%}
|
||||
|
||||
Reference in New Issue
Block a user