Compare commits
7 Commits
ab674ada31
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab4b7b3fe | ||
|
|
20d3ff8425 | ||
|
|
cf2e2ba1db | ||
|
|
5518c95237 | ||
|
|
322ae481ee | ||
|
|
65c4989d08 | ||
|
|
d2e07e047e |
@@ -1,44 +0,0 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
|
||||
|
||||
{% if bookings %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for booking in bookings %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if booking.end_at %}
|
||||
<span>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
||||
{% endif %}
|
||||
{% if booking.calendar_name %}
|
||||
<span>· {{ booking.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if booking.cost %}
|
||||
<span>· £{{ booking.cost }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if booking.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% elif booking.state == 'provisional' %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No bookings yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
|
||||
|
||||
{% if error %}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Account header #}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
|
||||
{% if g.user %}
|
||||
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
|
||||
{% if g.user.name %}
|
||||
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<form action="/auth/logout/" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
|
||||
>
|
||||
<i class="fa-solid fa-right-from-bracket text-xs"></i>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Labels #}
|
||||
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
|
||||
{% if labels %}
|
||||
<div>
|
||||
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for label in labels %}
|
||||
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
newsletters
|
||||
{% endcall %}
|
||||
{% for link in account_nav_links %}
|
||||
{% if link.external %}
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ link.href_fn() }}" class="{{styles.nav_button}}" data-hx-disable>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{% call links.link(link.href_fn(), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{{ link.label }}
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -1,17 +0,0 @@
|
||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
||||
<button
|
||||
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-target="#nl-{{ un.newsletter_id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
|
||||
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
|
||||
role="switch"
|
||||
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
|
||||
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
|
||||
|
||||
{% if newsletter_list %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for item in newsletter_list %}
|
||||
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
|
||||
{% if item.newsletter.description %}
|
||||
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
{% if item.un %}
|
||||
{% with un=item.un %}
|
||||
{% include "_types/auth/_newsletter_toggle.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{# No subscription row yet — show an off toggle that will create one #}
|
||||
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
||||
<button
|
||||
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-target="#nl-{{ item.newsletter.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No newsletters available.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/auth/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include oob.main %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
|
||||
|
||||
{% if tickets %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for ticket in tickets %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
|
||||
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
|
||||
{{ ticket.entry_name }}
|
||||
</a>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if ticket.calendar_name %}
|
||||
<span>· {{ ticket.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if ticket.ticket_type_name %}
|
||||
<span>· {{ ticket.ticket_type_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if ticket.state == 'checked_in' %}
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
|
||||
{% elif ticket.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No tickets yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "_types/root/index.html" %}
|
||||
{% block content %}
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
|
||||
|
||||
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
|
||||
If an account exists for
|
||||
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
|
||||
you’ll receive a link to sign in. It expires in 15 minutes.
|
||||
</p>
|
||||
|
||||
{% if email_error %}
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<span class="font-medium">Heads up:</span>
|
||||
<span>{{ email_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="mt-6 text-sm">
|
||||
<a
|
||||
href="{{ blog_url('/auth/login/') }}"
|
||||
class="text-stone-600 dark:text-stone-300 hover:underline"
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='auth-row', oob=oob) %}
|
||||
{% call links.link(account_url('/'), hx_select_search ) %}
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<div>account</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include "_types/auth/_nav.html" %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
|
||||
{% block auth_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include "_types/auth/_nav.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/auth/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends oob.extends %}
|
||||
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row(oob.child_id, oob.header) %}
|
||||
{% block auth_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include oob.nav %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include oob.main %}
|
||||
{% endblock %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "_types/root/index.html" %}
|
||||
{% block content %}
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Enter your email and we’ll email you a one-time sign-in link.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
method="post" action="{{ blog_url('/auth/start/') }}"
|
||||
class="mt-6 space-y-5"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ email or '' }}"
|
||||
required
|
||||
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
|
||||
>
|
||||
Send link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
|
||||
<div class="flex flex-wrap gap-2 px-4 py-3">
|
||||
{% if has_access('blog.new_post') %}
|
||||
{% set new_href = url_for('blog.new_post')|host %}
|
||||
<a
|
||||
href="{{ new_href }}"
|
||||
hx-get="{{ new_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
title="New Post"
|
||||
>
|
||||
<i class="fa fa-plus mr-1"></i> New Post
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if g.user and (draft_count or drafts) %}
|
||||
{% if drafts %}
|
||||
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
|
||||
<a
|
||||
href="{{ drafts_off_href }}"
|
||||
hx-get="{{ drafts_off_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
title="Hide Drafts"
|
||||
>
|
||||
<i class="fa fa-file-text-o mr-1"></i> Drafts
|
||||
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
|
||||
<a
|
||||
href="{{ drafts_on_href }}"
|
||||
hx-get="{{ drafts_on_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||
title="Show Drafts"
|
||||
>
|
||||
<i class="fa fa-file-text-o mr-1"></i> Drafts
|
||||
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,79 +0,0 @@
|
||||
{% 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>
|
||||
|
||||
{# Widget-driven card decorations #}
|
||||
{% for w in widgets.container_cards %}
|
||||
{% include w.template with context %}
|
||||
{% endfor %}
|
||||
|
||||
{% include '_types/blog/_card/at_bar.html' %}
|
||||
|
||||
</article>
|
||||
@@ -1,19 +0,0 @@
|
||||
<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>
|
||||
@@ -1,21 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,32 +0,0 @@
|
||||
{# --- 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 --- #}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% 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 --- #}
|
||||
@@ -1,59 +0,0 @@
|
||||
<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>
|
||||
@@ -1,111 +0,0 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
|
||||
{# View toggle bar - desktop only #}
|
||||
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
||||
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
||||
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
|
||||
<a
|
||||
href="{{ list_href }}"
|
||||
hx-get="{{ list_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||
title="List view"
|
||||
_="on click js localStorage.removeItem('blog_view') end"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="{{ tile_href }}"
|
||||
hx-get="{{ tile_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||
title="Tile view"
|
||||
_="on click js localStorage.setItem('blog_view','tile') end"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Cards container - list or grid based on view #}
|
||||
{% if view == 'tile' %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/blog/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="max-w-full px-3 py-3 space-y-3">
|
||||
{% include "_types/blog/_cards.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
@@ -1,40 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,9 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,79 +0,0 @@
|
||||
<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>
|
||||
@@ -1,17 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,9 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,73 +0,0 @@
|
||||
<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>
|
||||
@@ -1,16 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,20 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% 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>
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
{% 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>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
{# 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>
|
||||
@@ -1,59 +0,0 @@
|
||||
{% 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>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='blog-row', oob=oob) %}
|
||||
<div></div>
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,37 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
@@ -1,14 +0,0 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,31 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,55 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,259 +0,0 @@
|
||||
{# ── 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>
|
||||
@@ -1,12 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,7 +0,0 @@
|
||||
{% import "macros/links.html" as links %}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for('market.browse.product.admin', product_slug=slug)
|
||||
)}}
|
||||
{% endif %}
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||
{% include "_types/browse/_product_cards.html" %}
|
||||
</div>
|
||||
<div class="pb-8"></div>
|
||||
@@ -1,37 +0,0 @@
|
||||
{% 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 %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'market-header-child', '_types/market/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{# Filter container with child summary - from browse/index.html child_summary block #}
|
||||
{% block filter %}
|
||||
{% include "_types/browse/mobile/_filter/summary.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% include "_types/browse/desktop/menu.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/browse/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
@@ -1,104 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% import '_types/product/prices.html' as prices %}
|
||||
{% set prices_ns = namespace() %}
|
||||
{{ prices.set_prices(p, prices_ns) }}
|
||||
{% set item_href = url_for('market.browse.product.product_detail', product_slug=p.slug)|host %}
|
||||
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
|
||||
{# ❤️ like button overlay - OUTSIDE the link #}
|
||||
{% if g.user %}
|
||||
<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">
|
||||
{% set slug = p.slug %}
|
||||
{% set liked = p.is_liked or False %}
|
||||
{% include "_types/browse/like/button.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a
|
||||
href="{{ item_href }}"
|
||||
hx-get="{{ item_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class=""
|
||||
>
|
||||
|
||||
{# Make this relative so we can absolutely position children #}
|
||||
<div class="w-full aspect-square bg-stone-100 relative">
|
||||
{% if p.image %}
|
||||
<figure class="inline-block w-full h-full">
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
src="{{ p.image }}"
|
||||
alt="no image"
|
||||
class="absolute inset-0 w-full h-full object-contain object-top"
|
||||
loading="lazy" decoding="async" fetchpriority="low"
|
||||
/>
|
||||
|
||||
{% for l in p.labels %}
|
||||
<img
|
||||
src="{{ asset_url('labels/' + l + '.svg') }}"
|
||||
alt=""
|
||||
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
|
||||
/>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<figcaption class="
|
||||
mt-2 text-sm text-center
|
||||
{{ 'bg-yellow-200' if p.brand in selected_brands else '' }}
|
||||
text-stone-600
|
||||
">
|
||||
{{ p.brand }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{% else %}
|
||||
<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">
|
||||
<div class="text-stone-400 text-xs">No image</div>
|
||||
<ul class="flex flex-row gap-1">
|
||||
{% for l in p.labels %}
|
||||
<li>{{ l }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
|
||||
{{ p.brand }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{# <div>{{ prices.rrp(prices_ns) }}</div> #}
|
||||
{{ prices.card_price(p)}}
|
||||
|
||||
{% import '_types/product/_cart.html' as _cart %}
|
||||
</a>
|
||||
<div class="flex justify-center">
|
||||
{{ _cart.add(p.slug, cart)}}
|
||||
</div>
|
||||
|
||||
|
||||
<a
|
||||
href="{{ item_href }}"
|
||||
hx-get="{{ item_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div class="flex flex-row justify-center gap-2 p-2">
|
||||
{% for s in p.stickers %}
|
||||
{{ stick.sticker(
|
||||
asset_url('stickers/' + s + '.svg'),
|
||||
s,
|
||||
True,
|
||||
size=24,
|
||||
found=s in selected_stickers
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
|
||||
{{ p.title | highlight(search) }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,107 +0,0 @@
|
||||
{% for p in products %}
|
||||
{% include "_types/browse/_product_card.html" %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{# Categories #}
|
||||
<nav aria-label="Categories"
|
||||
class="rounded-xl border bg-white shadow-sm min-h-0">
|
||||
<ul class="divide-y">
|
||||
{% set top_active = (sub_slug is not defined or sub_slug is none or sub_slug == '') %}
|
||||
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
|
||||
<li>
|
||||
<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 top_active else 'false' }}"
|
||||
class="block px-4 py-3 text-[15px] transition {{select_colours}}">
|
||||
<div class="prose prose-stone max-w-none">All products</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for sub in subs_local %}
|
||||
{% set active = (sub.slug == sub_slug) %}
|
||||
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
|
||||
<li>
|
||||
<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 px-4 py-3 text-[15px] border-l-4 transition {{select_colours}}"
|
||||
>
|
||||
<div class="prose prose-stone max-w-none">{{ (sub.html_label or sub.name) | safe }}</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -1,40 +0,0 @@
|
||||
{# Brand filter (desktop, single-select) #}
|
||||
|
||||
{# Brands #}
|
||||
<nav aria-label="Brands"
|
||||
class="rounded-xl border bg-white shadow-sm">
|
||||
<h2 class="text-md mt-2 font-semibold">Brands</h2>
|
||||
<ul class="divide-y">
|
||||
{% for b in brands %}
|
||||
{% set is_selected = (b.name in selected_brands) %}
|
||||
{% if is_selected %}
|
||||
{% set brand_href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
|
||||
{% else %}
|
||||
{% set brand_href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ brand_href }}"
|
||||
hx-get="{{ brand_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML" hx-push-url="true" hx-on:htmx:afterSwap="this.closest('details')?.removeAttribute('open')"
|
||||
class="flex items-center gap-2 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
|
||||
{% if is_selected %}
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="flex-1 text-sm">{{ b.name }}</span>
|
||||
|
||||
{% if b.count is not none %}
|
||||
<span class="{% if b.count==0 %}text-lg text-red-500{% else %}text-sm{% endif %} {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
<ul
|
||||
id="labels-details-desktop"
|
||||
class="flex justify-center p-0 m-0 gap-2"
|
||||
>
|
||||
{% for s in labels %}
|
||||
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
|
||||
{% set qs = {"remove_label": s.name, "page":None}|qs if is_on
|
||||
else {"add_label": s.name, "page":None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex w-full h-full flex-col items-center justify-center py-2"
|
||||
>
|
||||
<!-- col 1: icon -->
|
||||
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
|
||||
|
||||
|
||||
<!-- col 3: count (right aligned) -->
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
leading-none justify-self-end tabular-nums">
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -1,38 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% set qs = {"liked": None if liked else True, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if liked else 'false' }}"
|
||||
title="liked" aria-label="liked"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
|
||||
{% if liked %}
|
||||
aria-label="liked and unliked"
|
||||
{% else %}
|
||||
aria-label="just liked"
|
||||
{% endif %}
|
||||
>
|
||||
{% if liked %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% else %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-stone-300 text-[40px] leading-none"
|
||||
></i>
|
||||
{% endif %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
aria_label="liked count"
|
||||
>
|
||||
{{ liked_count }}
|
||||
</span>
|
||||
</a>
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
{% macro search(current_local_href,search, search_count, hx_select) -%}
|
||||
<!-- Search (1/3 width → 4/12 columns) -->
|
||||
<!-- nb this does NOT oob itself!! -->
|
||||
<div
|
||||
id="search-desktop-wrapper"
|
||||
class="flex flex-row gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
id="search-desktop"
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="search"
|
||||
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
hx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div
|
||||
id="search-count-desktop"
|
||||
aria-label="search count"
|
||||
{% if not search_count %}
|
||||
class="text-xl text-red-500"
|
||||
{% endif %}
|
||||
>
|
||||
{% if search %}
|
||||
{{search_count}}
|
||||
{% endif %}
|
||||
{{zap_filter}}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,34 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% set sort_val = sort|default('az', true) %}
|
||||
|
||||
<ul
|
||||
id="sort-details-desktop"
|
||||
class="flex w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-0 [&>li]:list-none [&>li]:flex-1"
|
||||
>
|
||||
{% for key,label,icon in sort_options %}
|
||||
{% set is_on = (sort_val == key) %}
|
||||
{% set qs = {"sort": None, "page": None}|qs if is_on
|
||||
else {"sort": key, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
class="flex flex-col items-center justify-center w-full h-full py-2 m-0"
|
||||
>
|
||||
{{ stick.sticker(asset_url(icon), label, is_on) }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
<ul
|
||||
id="stickers-details-desktop"
|
||||
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
|
||||
>
|
||||
{% for s in stickers %}
|
||||
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
|
||||
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
|
||||
else {"add_sticker": s.name, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host%}
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex w-full h-full flex-col items-center justify-center py-2"
|
||||
>
|
||||
<span class="text-[11px]">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
|
||||
<!-- col 1: icon -->
|
||||
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on)}}
|
||||
|
||||
|
||||
<!-- col 3: count (right aligned) -->
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
leading-none justify-self-end tabular-nums">
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -1,37 +0,0 @@
|
||||
{% import '_types/browse/desktop/_filter/search.html' as s %}
|
||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
||||
|
||||
<div
|
||||
id="category-summary-desktop"
|
||||
hxx-swap-oob="outerHTML"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="text-2xl uppercase tracking-wide text-black-500">{{ category_label }}</div>
|
||||
</div>
|
||||
{% include "_types/browse/desktop/_filter/sort.html" %}
|
||||
<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">
|
||||
{% include "_types/browse/desktop/_filter/like.html" %}
|
||||
{% if labels %}
|
||||
{% include "_types/browse/desktop/_filter/labels.html" %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if stickers %}
|
||||
{% include "_types/browse/desktop/_filter/stickers.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if subs_local and top_local_href %}
|
||||
{% include "_types/browse/desktop/_category_selector.html" %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="filter-summary-desktop"
|
||||
hxx-swap-oob="outerHTML"
|
||||
>
|
||||
|
||||
{% include "_types/browse/desktop/_filter/brand.html" %}
|
||||
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
{% extends '_types/market/index.html' %}
|
||||
|
||||
{% block filter %}
|
||||
{% include "_types/browse/mobile/_filter/summary.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% include "_types/browse/desktop/menu.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/browse/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
@@ -1,40 +0,0 @@
|
||||
<nav aria-label="Brands" class="px-4 pb-3" >
|
||||
{% if brands|length %}
|
||||
<h2 class="text-md mt-2 font-semibold">Brands</h2>
|
||||
<ul class="space-y-1 pr-1" >
|
||||
{% for b in brands %}
|
||||
{% set is_selected = (b.name in selected_brands) %}
|
||||
<li>
|
||||
{{current_local_href}}
|
||||
<a
|
||||
{% if is_selected %}
|
||||
{% set href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
|
||||
{% else %}
|
||||
{% set href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
|
||||
{%endif%}
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
|
||||
class="flex items-center gap-2 my-3 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
|
||||
{% if is_selected %}
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="flex-1 text-sm">{{ b.name }}</span>
|
||||
{% if b.count is not none %}
|
||||
<span class="text-xs {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -1,30 +0,0 @@
|
||||
|
||||
{% include "_types/browse/mobile/_filter/sort_ul.html" %}
|
||||
{% if search or selected_labels|length or selected_stickers|length or selected_brands|length %}
|
||||
{% set href = (current_local_href ~ {"clear_filters": True}|qs)|host %}
|
||||
<div class = "flex flex-row justify-center">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
title="clear filters"
|
||||
aria-label="clear filters"
|
||||
class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">
|
||||
<span class="mt-1 leading-none tabular-nums"
|
||||
>
|
||||
clear filters
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-row gap-2 justify-center items center">
|
||||
{% include "_types/browse/mobile/_filter/like.html" %}
|
||||
{% include "_types/browse/mobile/_filter/labels.html" %}
|
||||
</div>
|
||||
{% include "_types/browse/mobile/_filter/stickers.html" %}
|
||||
{% include "_types/browse/mobile/_filter/brand_ul.html" %}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
<nav aria-label="labels" class="px-4 pb-3">
|
||||
{# One row only; center when not overflowing; horizontal scroll when needed #}
|
||||
<ul
|
||||
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
|
||||
>
|
||||
|
||||
{% for s in labels %}
|
||||
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
|
||||
|
||||
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on
|
||||
else {"add_label": s.name, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
|
||||
<li class="list-none shrink-0">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
|
||||
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
|
||||
<style>
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
<nav aria-label="like" class="px-4 pb-3">
|
||||
{% set qs = {"liked": None if liked else True, "page": None}|qs%}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if liked else 'false' }}"
|
||||
title="liked" aria-label="liked"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
|
||||
{% if liked %}
|
||||
aria-label="liked and unliked"
|
||||
{% else %}
|
||||
aria-label="just liked"
|
||||
{% endif %}
|
||||
>
|
||||
{% if liked %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% else %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-stone-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% endif %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
aria_label="liked count"
|
||||
>
|
||||
{{ liked_count }}
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -1,40 +0,0 @@
|
||||
{% macro search(current_local_href, search, search_count, hx_select) -%}
|
||||
|
||||
<div
|
||||
id="search-mobile-wrapper"
|
||||
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
>
|
||||
<input
|
||||
id="search-mobile"
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="search"
|
||||
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
hx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div
|
||||
id="search-count-mobile"
|
||||
aria-label="search count"
|
||||
{% if not search_count %}
|
||||
class="text-xl text-red-500"
|
||||
{% endif %}
|
||||
>
|
||||
{% if search %}
|
||||
{{search_count}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
|
||||
<nav aria-label="sort" class="px-4 pb-3" >
|
||||
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
|
||||
|
||||
{% for key,label,icon in sort_options %}
|
||||
<li class="list-none">
|
||||
<div class="flex flex-col items-center justify-center w-full">
|
||||
<a
|
||||
{% if sort == key %}
|
||||
{% set href= (current_local_href, {"sort": None, "page": None}|qs )|host %}
|
||||
{% else %}
|
||||
{% set href= (current_local_href ~ {"sort": key, "page": None}|qs )|host %}
|
||||
{% endif %}
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
{{ stick.sticker(asset_url(icon), label, sort==key) }}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -1,50 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
<nav aria-label="stickers" class="px-4 pb-3">
|
||||
{# One row only; center when not overflowing; horizontal scroll when needed #}
|
||||
<ul
|
||||
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
|
||||
>
|
||||
|
||||
{% for s in stickers %}
|
||||
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
|
||||
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
|
||||
else {"add_sticker": s.name, "page": None}|qs %}
|
||||
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
|
||||
<li class="list-none shrink-0">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
|
||||
<span class="text-sm">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
|
||||
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on) }}
|
||||
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
|
||||
<style>
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% 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) %}
|
||||
<div
|
||||
class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2"
|
||||
role="list">
|
||||
|
||||
|
||||
<div class="flex flex-row items-start gap-2">
|
||||
{% if sort %}
|
||||
<ul class="relative inline-flex items-center justify-center gap-2">
|
||||
<!-- sticker icon -->
|
||||
{% for k,l,i in sort_options %}
|
||||
{% if k == sort %}
|
||||
{% set key = k %}
|
||||
{% set label = l %}
|
||||
{% set icon = i %}
|
||||
<li role="listitem">
|
||||
{{ stick.sticker(asset_url(icon), label, True)}}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if liked %}
|
||||
<div class="flex flex-col items-center gap-1 pb-1">
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% if liked_count is not none %}
|
||||
<div class="
|
||||
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums"
|
||||
>
|
||||
{{ liked_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_labels and selected_labels|length %}
|
||||
<ul class="relative inline-flex items-center justify-center gap-2">
|
||||
{% for st in selected_labels %}
|
||||
{% for s in labels %}
|
||||
{% if st == s.name %}
|
||||
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
|
||||
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, True)}}
|
||||
{% if s.count is not none %}
|
||||
<div class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if selected_stickers and selected_stickers|length %}
|
||||
<ul class="relative inline-flex items-center justify-center gap-2">
|
||||
{% for st in selected_stickers %}
|
||||
{% for s in stickers %}
|
||||
{% if st == s.name %}
|
||||
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
|
||||
<!-- sticker icon -->
|
||||
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, True)}}
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if selected_brands and selected_brands|length %}
|
||||
<ul class_="w-full grid grid-cols-12 items-center gap-3 px-4 py-3">
|
||||
{% for b in selected_brands %}
|
||||
<li role="listitem" class="flex flex-row items-center gap-2">
|
||||
{% set ns = namespace(count=0) %}
|
||||
{% for brand in brands %}
|
||||
{% if brand.name == b %}
|
||||
{% set ns.count = brand.count %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns.count %}
|
||||
<div class="text-md">{{ b }}</div>
|
||||
<div class="text-md">{{ ns.count }}</div>
|
||||
{% else %}
|
||||
<div class="text-md text-red-500">{{ b }}</div>
|
||||
<div class="text-xl text-red-500">0</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
<div id="filter-details-mobile" style="display:contents">
|
||||
{% include "_types/browse/mobile/_filter/index.html" %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% macro description(calendar, oob=False) %}
|
||||
<div
|
||||
id="calendar-description-title"
|
||||
{% if oob %}
|
||||
hx-swap-oob="outerHTML"
|
||||
{% endif %}
|
||||
class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
|
||||
>
|
||||
{{ calendar.description or ''}}
|
||||
</div>
|
||||
|
||||
{% endmacro %}
|
||||
@@ -1,180 +0,0 @@
|
||||
<section class="bg-orange-100">
|
||||
<header class="flex items-center justify-center mt-2">
|
||||
|
||||
{# Month / year navigation #}
|
||||
<nav class="flex items-center gap-2 text-2xl">
|
||||
{# Outer left: -1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
«
|
||||
</a>
|
||||
|
||||
{# Inner left: -1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
|
||||
<div class="px-3 font-medium">
|
||||
{{ month_name }} {{ year }}
|
||||
</div>
|
||||
|
||||
{# Inner right: +1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
›
|
||||
</a>
|
||||
|
||||
{# Outer right: +1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
»
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{# Calendar grid #}
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">
|
||||
{# Weekday header: only show on sm+ (desktop/tablet) #}
|
||||
<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">
|
||||
{% for wd in weekday_names %}
|
||||
<div class="py-1">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# On mobile: 1 column; on sm+: 7 columns #}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">
|
||||
{% for week in weeks %}
|
||||
{% for day in week %}
|
||||
<div
|
||||
class="min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %} {% if day.is_today %} ring-2 ring-blue-500 z-10 relative {% endif %}"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col">
|
||||
<span class="sm:hidden text-[16px] text-stone-500">
|
||||
{{ day.date.strftime('%a') }}
|
||||
</span>
|
||||
|
||||
{# Clickable day number: goes to day detail view #}
|
||||
<a
|
||||
class="{{styles.pill}}"
|
||||
href="{{ url_for('calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
{{ day.date.day }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# Entries for this day: merged, chronological #}
|
||||
<div class="mt-1 space-y-0.5">
|
||||
{# Build a list of entries for this specific day.
|
||||
month_entries is already sorted by start_at in Python. #}
|
||||
{% for e in month_entries %}
|
||||
{% if e.start_at.date() == day.date %}
|
||||
{# Decide colour: highlight "mine" differently if you want #}
|
||||
{% set is_mine = (g.user and e.user_id == g.user.id)
|
||||
or (not g.user and e.session_id == qsession.get('calendar_sid')) %}
|
||||
<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5
|
||||
{% if e.state == 'confirmed' %}
|
||||
{% if is_mine %}
|
||||
bg-emerald-200 text-emerald-900
|
||||
{% else %}
|
||||
bg-emerald-100 text-emerald-800
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if is_mine %}
|
||||
bg-sky-100 text-sky-800
|
||||
{% else %}
|
||||
bg-stone-100 text-stone-700
|
||||
{% endif %}
|
||||
{% endif %}">
|
||||
<span class="truncate">
|
||||
{{ e.name }}
|
||||
</span>
|
||||
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">
|
||||
{{ (e.state or 'pending')|replace('_', ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +0,0 @@
|
||||
<!-- Desktop nav -->
|
||||
{% import 'macros/links.html' as links %}
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/slots/') }}" class="{{styles.nav_button}}">
|
||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||
<div>Slots</div>
|
||||
</a>
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/admin/') }}" class="{{styles.nav_button}}">
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends "oob_elements.html" %}
|
||||
{# OOB elements for post admin page #}
|
||||
|
||||
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('post-header-child', 'calendar-header-child', '_types/calendar/header/_header.html')}}
|
||||
|
||||
{% from '_types/post/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,33 +0,0 @@
|
||||
<div id="calendar-description">
|
||||
{% if calendar.description %}
|
||||
<p class="text-stone-700 whitespace-pre-line break-all">
|
||||
{{ calendar.description }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-stone-400 italic">
|
||||
No description yet.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-xs underline"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_edit',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if oob %}
|
||||
|
||||
{% from '_types/calendar/_description.html' import description %}
|
||||
{{description(calendar, oob=True)}}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<div id="calendar-description">
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_save',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<textarea
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
rows="4"
|
||||
class="w-full p-2 border rounded"
|
||||
>{{ calendar.description or '' }}</textarea>
|
||||
|
||||
<div class="mt-2 flex gap-2 text-xs">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-1 rounded bg-stone-800 text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded border"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_view',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
<section class="max-w-3xl mx-auto p-4 space-y-10">
|
||||
<!-- Calendar config -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Calendar configuration</h2>
|
||||
<div id="cal-put-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700">
|
||||
Description
|
||||
</label>
|
||||
{% include '_types/calendar/admin/_description.html' %}
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="calendar-form"
|
||||
method="post"
|
||||
hx-put="{{ url_for('calendars.calendar.put', slug=post.slug, calendar_slug=calendar.slug ) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"
|
||||
hx-on::response-error="document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
|
||||
class="hidden space-y-4 mt-4"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-stone-700">Description</label>
|
||||
<div>{{calendar.description or ''}}</div>
|
||||
<textarea
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
rows="4" class="w-full p-2 border rounded"
|
||||
>{{ (calendar.description or '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="px-3 py-2 rounded bg-stone-800 text-white">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr class="border-stone-200">
|
||||
|
||||
</section>
|
||||
@@ -1,2 +0,0 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
@@ -1,25 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for calendar admin page #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('calendar-header-child', 'calendar-admin-header-child', '_types/calendar/admin/header/_header.html')}}
|
||||
|
||||
{% from '_types/calendar/header/_header.html' import header_row with context %}
|
||||
{{header_row(oob=True)}}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/calendar/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,14 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for('calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug),
|
||||
hx_select_search
|
||||
) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendar/admin/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends '_types/calendar/index.html' %}
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
|
||||
{% block calendar_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% call header() %}
|
||||
{% from '_types/calendar/admin/header/_header.html' import header_row with context %}
|
||||
{{ header_row() }}
|
||||
<div id="calendar-admin-header-child">
|
||||
{% block calendar_admin_header_child %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendar/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,23 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendar-row', oob=oob) %}
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') }}" class="flex items-center gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<div class="shrink-0">
|
||||
{{ calendar.name }}
|
||||
</div>
|
||||
</div>
|
||||
{% from '_types/calendar/_description.html' import description %}
|
||||
{{description(calendar)}}
|
||||
</div>
|
||||
</a>
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{% extends '_types/post/index.html' %}
|
||||
|
||||
{% block post_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('calendar-header-child', '_types/calendar/header/_header.html') %}
|
||||
{% block calendar_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendar/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,44 +0,0 @@
|
||||
{% for row in calendars %}
|
||||
{% set cal = row %}
|
||||
<div class="mt-6 border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
{% set calendar_href = url_for('calendars.calendar.get', slug=post.slug, calendar_slug=cal.slug)|host%}
|
||||
<a
|
||||
class="flex items-baseline gap-3"
|
||||
href="{{ calendar_href }}"
|
||||
hx-get="{{ calendar_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<h3 class="font-semibold">{{ cal.name }}</h3>
|
||||
<h4 class="text-gray-500">/{{ cal.slug }}/</h4>
|
||||
</a>
|
||||
|
||||
<!-- Soft delete -->
|
||||
<button
|
||||
class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
|
||||
data-confirm
|
||||
data-confirm-title="Delete calendar?"
|
||||
data-confirm-text="Entries will be hidden (soft delete)"
|
||||
data-confirm-icon="warning"
|
||||
data-confirm-confirm-text="Yes, delete it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
hx-delete="{{ url_for('calendars.calendar.delete', slug=post.slug, calendar_slug=cal.slug) }}"
|
||||
hx-trigger="confirmed"
|
||||
hx-target="#calendars-list"
|
||||
hx-select="#calendars-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-headers='{"X-CSRFToken":"{{ csrf_token() }}"}'
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 mt-4">No calendars yet. Create one above.</p>
|
||||
{% endfor %}
|
||||
@@ -1,27 +0,0 @@
|
||||
<section class="p-4">
|
||||
{% if has_access('calendars.create_calendar') %}
|
||||
<!-- error container under the inputs -->
|
||||
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
|
||||
<form
|
||||
class="mt-4 flex gap-2 items-end"
|
||||
hx-post="{{ url_for('calendars.create_calendar', slug=post.slug) }}"
|
||||
hx-target="#calendars-list"
|
||||
hx-select="#calendars-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::before-request="document.querySelector('#cal-create-errors').textContent='';"
|
||||
hx-on::response-error="document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-600">Name</label>
|
||||
<input name="name" type="text" required class="w-full border rounded px-3 py-2" placeholder="e.g. Events, Gigs, Meetings" />
|
||||
</div>
|
||||
<button type="submit" class="border rounded px-3 py-2">Add calendar</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<!-- list -->
|
||||
<div id="calendars-list" class="mt-6">
|
||||
{% include "_types/calendars/_calendars_list.html" %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,2 +0,0 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
@@ -1,28 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('post-admin-header-child', 'calendars-header-child', '_types/calendars/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/calendars/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/calendars/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendars-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-calendar" aria-hidden="true"></i>
|
||||
<div>Calendars</div>
|
||||
</a>
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendars/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% 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('calendars-header-child', '_types/calendars/header/_header.html') %}
|
||||
{% block calendars_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendars/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/calendars/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="max-w-full px-3 py-3 space-y-3">
|
||||
{% from '_types/cart/_cart.html' import show_cart with context %}
|
||||
{{ show_cart() }}
|
||||
</div>
|
||||
@@ -1,2 +0,0 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
@@ -1,28 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'cart-header-child', '_types/cart/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/cart/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/cart/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{% extends '_types/root/index.html' %}
|
||||
|
||||
{% block filter %}
|
||||
<header class="mb-6 sm:mb-8">
|
||||
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Checkout error
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm text-stone-600">
|
||||
We tried to start your payment with SumUp but hit a problem.
|
||||
</p>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-full px-3 py-3 space-y-4">
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
|
||||
<p class="font-medium">Something went wrong.</p>
|
||||
<p>
|
||||
{{ error or "Unexpected error while creating the hosted checkout session." }}
|
||||
</p>
|
||||
{% if order %}
|
||||
<p class="text-xs text-rose-800/80">
|
||||
Order ID: <span class="font-mono">#{{ order.id }}</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="{{ cart_url('/') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
|
||||
>
|
||||
<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>
|
||||
Back to cart
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,68 +0,0 @@
|
||||
{% extends '_types/root/index.html' %}
|
||||
|
||||
{% block filter %}
|
||||
<header class="mb-1 sm:mb-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{% if order.status == 'paid' %}
|
||||
Payment received
|
||||
{% elif order.status == 'failed' %}
|
||||
Payment failed
|
||||
{% elif order.status == 'missing' %}
|
||||
Order not found
|
||||
{% else %}
|
||||
Payment status: {{ order.status|default('pending')|capitalize }}
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm text-stone-600">
|
||||
{% if order.status == 'paid' %}
|
||||
Thanks for your order.
|
||||
{% elif order.status == 'failed' %}
|
||||
Something went wrong while processing your payment. You can try again below.
|
||||
{% elif order.status == 'missing' %}
|
||||
We couldn't find that order – it may have expired or never been created.
|
||||
{% else %}
|
||||
We’re still waiting for a final confirmation from SumUp.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{# no aside content for now #}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-full px-1 py-1">
|
||||
{% if order %}
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2">
|
||||
{% include '_types/order/_summary.html' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-rose-300 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-800">
|
||||
We couldn’t find that order. If you reached this page from an old link, please start a new order.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include '_types/order/_items.html' %}
|
||||
{% include '_types/order/_calendar_items.html' %}
|
||||
{% include '_types/order/_ticket_items.html' %}
|
||||
|
||||
{% if order.status == 'failed' and order %}
|
||||
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
|
||||
<p class="font-medium">Your payment was not completed.</p>
|
||||
<p>
|
||||
You can go back to your cart and try checkout again. If the problem persists,
|
||||
please contact us and mention order <span class="font-mono">#{{ order.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
{% elif order.status == 'paid' %}
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 sm:p-6 text-sm text-emerald-900 space-y-2">
|
||||
<p class="font-medium">All done!</p>
|
||||
<p>We’ll start processing your order shortly.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='cart-row', oob=oob) %}
|
||||
{% call links.link(cart_url('/'), hx_select_search ) %}
|
||||
<i class="fa fa-shopping-cart"></i>
|
||||
<h2 class="text-xl font-bold">cart</h2>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/cart/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('cart-header-child', '_types/cart/header/_header.html') %}
|
||||
{% block cart_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/cart/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/cart/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,301 +0,0 @@
|
||||
<div id="entry-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
|
||||
<form
|
||||
class="mt-4 grid grid-cols-1 md:grid-cols-4 gap-2"
|
||||
hx-post="{{ url_for(
|
||||
'calendars.calendar.day.calendar_entries.add_entry',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#day-entries"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# 1) Entry name #}
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
class="border rounded px-3 py-2"
|
||||
placeholder="Entry name"
|
||||
/>
|
||||
|
||||
{# 2) Slot picker for this weekday (required) #}
|
||||
{% if day_slots %}
|
||||
<select
|
||||
name="slot_id"
|
||||
class="border rounded px-3 py-2"
|
||||
data-slot-picker
|
||||
required
|
||||
>
|
||||
{% for slot in day_slots %}
|
||||
<option
|
||||
value="{{ slot.id }}"
|
||||
data-start="{{ slot.time_start.strftime('%H:%M') }}"
|
||||
data-end="{{ slot.time_end.strftime('%H:%M') if slot.time_end else '' }}"
|
||||
data-flexible="{{ '1' if slot | getattr('flexible', False) else '0' }}"
|
||||
data-cost="{{ slot.cost if slot.cost is not none else '0' }}"
|
||||
>
|
||||
{{ slot.name }}
|
||||
({{ slot.time_start.strftime('%H:%M') }}
|
||||
{% if slot.time_end %}–{{ slot.time_end.strftime('%H:%M') }}{% else %}–open-ended{% endif %})
|
||||
{% if slot | getattr('flexible', False) %}[flexible]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="text-sm text-stone-500">
|
||||
No slots defined for this day.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# 3) Time entry + cost display #}
|
||||
<div class="md:col-span-2 flex flex-col gap-2">
|
||||
{# Time inputs — hidden until a flexible slot is selected #}
|
||||
<div data-time-fields class="hidden">
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">From</label>
|
||||
<input
|
||||
name="start_time"
|
||||
type="time"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
data-entry-start
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">To</label>
|
||||
<input
|
||||
name="end_time"
|
||||
type="time"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
data-entry-end
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-stone-500" data-slot-boundary></p>
|
||||
</div>
|
||||
|
||||
{# Cost display — shown when a slot is selected #}
|
||||
<div data-cost-row class="hidden text-sm font-medium text-stone-700">
|
||||
Estimated Cost: <span data-cost-display class="text-green-600">£0.00</span>
|
||||
</div>
|
||||
|
||||
{# Summary of fixed times — shown for non-flexible slots #}
|
||||
<div data-fixed-summary class="hidden text-sm text-stone-600"></div>
|
||||
</div>
|
||||
|
||||
{# Ticket Configuration #}
|
||||
<div class="md:col-span-4 border-t pt-3 mt-2">
|
||||
<h4 class="text-sm font-semibold text-stone-700 mb-3">Ticket Configuration (Optional)</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">
|
||||
Ticket Price (£)
|
||||
</label>
|
||||
<input
|
||||
name="ticket_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full border rounded px-3 py-2 text-sm"
|
||||
placeholder="Leave empty for no tickets"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">
|
||||
Total Tickets
|
||||
</label>
|
||||
<input
|
||||
name="ticket_count"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full border rounded px-3 py-2 text-sm"
|
||||
placeholder="Leave empty for unlimited"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2 md:col-span-4">
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.add_button',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#entry-add-container"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="{{styles.action_button}}"
|
||||
data-confirm="true"
|
||||
data-confirm-title="Add entry?"
|
||||
data-confirm-text="Are you sure you want to add this entry?"
|
||||
data-confirm-icon="question"
|
||||
data-confirm-confirm-text="Yes, add it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
>
|
||||
<i class="fa fa-save"></i>
|
||||
Save entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- Behaviour: lock / unlock times based on slot.flexible --- #}
|
||||
<script>
|
||||
(function () {
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function calculateCost(slotCost, slotStart, slotEnd, actualStart, actualEnd, flexible) {
|
||||
if (!flexible) {
|
||||
// Fixed slot: use full slot cost
|
||||
return parseFloat(slotCost);
|
||||
}
|
||||
|
||||
// Flexible slot: prorate based on time range
|
||||
if (!actualStart || !actualEnd) return 0;
|
||||
|
||||
const slotStartMin = timeToMinutes(slotStart);
|
||||
const slotEndMin = timeToMinutes(slotEnd);
|
||||
const actualStartMin = timeToMinutes(actualStart);
|
||||
const actualEndMin = timeToMinutes(actualEnd);
|
||||
|
||||
const slotDuration = slotEndMin - slotStartMin;
|
||||
const actualDuration = actualEndMin - actualStartMin;
|
||||
|
||||
if (slotDuration <= 0 || actualDuration <= 0) return 0;
|
||||
|
||||
const ratio = actualDuration / slotDuration;
|
||||
return parseFloat(slotCost) * ratio;
|
||||
}
|
||||
|
||||
function initEntrySlotPicker(root, applyInitial = false) {
|
||||
const select = root.querySelector('[data-slot-picker]');
|
||||
if (!select) return;
|
||||
|
||||
const timeFields = root.querySelector('[data-time-fields]');
|
||||
const startInput = root.querySelector('[data-entry-start]');
|
||||
const endInput = root.querySelector('[data-entry-end]');
|
||||
const helper = root.querySelector('[data-slot-boundary]');
|
||||
const costDisplay = root.querySelector('[data-cost-display]');
|
||||
const costRow = root.querySelector('[data-cost-row]');
|
||||
const fixedSummary = root.querySelector('[data-fixed-summary]');
|
||||
|
||||
if (!startInput || !endInput) return;
|
||||
|
||||
function updateCost() {
|
||||
const opt = select.selectedOptions[0];
|
||||
if (!opt || !opt.value) {
|
||||
if (costDisplay) costDisplay.textContent = '£0.00';
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = opt.dataset.cost || '0';
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
const calculatedCost = calculateCost(
|
||||
cost, s, e,
|
||||
startInput.value, endInput.value,
|
||||
flexible
|
||||
);
|
||||
|
||||
if (costDisplay) {
|
||||
costDisplay.textContent = '£' + calculatedCost.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFromOption(opt) {
|
||||
if (!opt || !opt.value) {
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (costRow) costRow.classList.add('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
if (!flexible) {
|
||||
// Fixed slot: hide time inputs, show summary + cost
|
||||
if (s) startInput.value = s;
|
||||
if (e) endInput.value = e;
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (fixedSummary) {
|
||||
fixedSummary.classList.remove('hidden');
|
||||
if (e) {
|
||||
fixedSummary.textContent = `${s} – ${e}`;
|
||||
} else {
|
||||
fixedSummary.textContent = `From ${s} (open-ended)`;
|
||||
}
|
||||
}
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
} else {
|
||||
// Flexible slot: show time inputs, hide fixed summary, show cost
|
||||
if (timeFields) timeFields.classList.remove('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
if (helper) {
|
||||
if (e) {
|
||||
helper.textContent = `Times must be between ${s} and ${e}.`;
|
||||
} else {
|
||||
helper.textContent = `Start at or after ${s}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCost();
|
||||
}
|
||||
|
||||
// Only apply initial state if explicitly requested (on first load)
|
||||
if (applyInitial) {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
}
|
||||
|
||||
// Remove any existing listener to prevent duplicates
|
||||
if (select._slotChangeHandler) {
|
||||
select.removeEventListener('change', select._slotChangeHandler);
|
||||
}
|
||||
|
||||
select._slotChangeHandler = () => {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
};
|
||||
|
||||
select.addEventListener('change', select._slotChangeHandler);
|
||||
|
||||
// Update cost when times change (for flexible slots)
|
||||
startInput.addEventListener('input', updateCost);
|
||||
endInput.addEventListener('input', updateCost);
|
||||
}
|
||||
|
||||
// Initial load - apply initial state
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEntrySlotPicker(document, true);
|
||||
});
|
||||
|
||||
// HTMX fragments - apply initial state so visibility is correct
|
||||
if (window.htmx) {
|
||||
htmx.onLoad((content) => {
|
||||
initEntrySlotPicker(content, true);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user