feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract events/calendar functionality into standalone microservice:
- app.py and events_api.py from apps/events/
- Calendar blueprints (calendars, calendar, calendar_entries, calendar_entry, day, slots, slot, ticket_types, ticket_type)
- Templates for all calendar/event views including admin
- Dockerfile (APP_MODULE=app:app, IMAGE=events)
- entrypoint.sh (no Alembic - migrations managed by blog app)
- Gitea CI workflow for build and deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:16:32 +00:00
commit 3c0fa45f8c
119 changed files with 7163 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{% 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 %}

View File

@@ -0,0 +1,180 @@
<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('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-get="{{ url_for('blog.post.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"
>
&laquo;
</a>
{# Inner left: -1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_month_year,
month=prev_month) }}"
hx-get="{{ url_for('blog.post.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"
>
&lsaquo;
</a>
<div class="px-3 font-medium">
{{ month_name }} {{ year }}
</div>
{# Inner right: +1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_month_year,
month=next_month) }}"
hx-get="{{ url_for('blog.post.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"
>
&rsaquo;
</a>
{# Outer right: +1 year #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_year,
month=month) }}"
hx-get="{{ url_for('blog.post.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"
>
&raquo;
</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('blog.post.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('blog.post.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>

View File

@@ -0,0 +1,17 @@
<!-- Desktop nav -->
{% import 'macros/links.html' as links %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug=calendar.slug),
hx_select_search,
select_colours,
aclass=styles.nav_button
) %}
<i class="fa fa-clock" aria-hidden="true"></i>
<div>
Slots
</div>
{% endcall %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug))}}
{% endif %}

View File

@@ -0,0 +1,22 @@
{% 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 %}

View File

@@ -0,0 +1,33 @@
<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(
'blog.post.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 %}

View File

@@ -0,0 +1,43 @@
<div id="calendar-description">
<form
hx-post="{{ url_for(
'blog.post.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(
'blog.post.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>

View File

@@ -0,0 +1,46 @@
<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('blog.post.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>

View File

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

View File

@@ -0,0 +1,25 @@
{% 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 %}

View File

@@ -0,0 +1,14 @@
{% 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('blog.post.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 %}

View File

@@ -0,0 +1,24 @@
{% 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 %}

View File

@@ -0,0 +1,23 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendar-row', oob=oob) %}
{% call links.link(url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug= calendar.slug), hx_select_search) %}
<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>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/calendar/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% 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 %}

View File

@@ -0,0 +1,44 @@
{% 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('blog.post.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('blog.post.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 %}

View File

@@ -0,0 +1,27 @@
<section class="p-4">
{% if has_access('blog.post.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('blog.post.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>

View File

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

View File

@@ -0,0 +1,28 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{% 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 %}

View File

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

View File

@@ -0,0 +1,22 @@
{% 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 %}

View File

@@ -0,0 +1,301 @@
<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(
'blog.post.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('blog.post.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>

View File

@@ -0,0 +1,17 @@
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.add_form',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
) }}"
hx-target="#entry-add-container"
hx-swap="innerHTML"
>
+ Add entry
</button>

View File

@@ -0,0 +1,28 @@
<section id="day-entries" class="{{styles.list_container}}">
<table class="w-full text-sm border table-fixed">
<thead class="bg-stone-100">
<tr>
<th class="p-2 text-left w-2/6">Name</th>
<th class="text-left p-2 w-1/6">Slot/Time</th>
<th class="text-left p-2 w-1/6">State</th>
<th class="text-left p-2 w-1/6">Cost</th>
<th class="text-left p-2 w-1/6">Tickets</th>
<th class="text-left p-2 w-1/6">Actions</th>
</tr>
</thead>
<tbody>
{% for entry in day_entries %}
{% include '_types/day/_row.html' %}
{% else %}
<tr><td colspan="6" class="p-3 text-stone-500">No entries yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div id="entry-add-container" class="mt-4">
{% include '_types/day/_add_button.html' %}
</div>
</section>

View File

@@ -0,0 +1,41 @@
{% import 'macros/links.html' as links %}
{# Confirmed Entries - vertical on mobile, horizontal with arrows on desktop #}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="day-entries-nav-wrapper">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
entry_id=entry.id) }}"
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600 truncate">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
</a>
{% endcall %}
</div>
{# Admin link #}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for(
'blog.post.calendars.calendar.day.admin.admin',
slug=post.slug,
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,
day=day_date.day
)
)}}
{% endif %}

View File

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

View File

@@ -0,0 +1,76 @@
{% import 'macros/links.html' as links %}
<tr class="{{ styles.tr }}">
<td class="p-2 align-top w-2/6">
<div class="font-medium">
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
),
hx_select_search,
aclass=styles.pill
) %}
{{ entry.name }}
{% endcall %}
</div>
</td>
<td class="p-2 align-top w-1/6">
{% if entry.slot %}
<div class="text-xs font-medium">
{% call links.link(
url_for(
'blog.post.calendars.calendar.slots.slot.get',
slug=post.slug,
calendar_slug=calendar.slug,
slot_id=entry.slot.id
),
hx_select_search,
aclass=styles.pill
) %}
{{ entry.slot.name }}
{% endcall %}
<span class="text-stone-600 font-normal">
({{ entry.slot.time_start.strftime('%H:%M') }}{% if entry.slot.time_end %} → {{ entry.slot.time_end.strftime('%H:%M') }}{% endif %})
</span>
</div>
{% else %}
<div class="text-xs text-stone-600">
{% include '_types/entry/_times.html' %}
</div>
{% endif %}
</td>
<td class="p-2 align-top w-1/6">
<div id="entry-state-{{entry.id}}">
{% include '_types/entry/_state.html' %}
</div>
</td>
<td class="p-2 align-top w-1/6">
<span class="font-medium text-green-600">
£{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }}
</span>
</td>
<td class="p-2 align-top w-1/6">
{% if entry.ticket_price is not none %}
<div class="text-xs space-y-1">
<div class="font-medium text-green-600">£{{ ('%.2f'|format(entry.ticket_price)) }}</div>
<div class="text-stone-600">
{% if entry.ticket_count is not none %}
{{ entry.ticket_count }} tickets
{% else %}
Unlimited
{% endif %}
</div>
</div>
{% else %}
<span class="text-xs text-stone-400">No tickets</span>
{% endif %}
</td>
<td class="p-2 align-top w-1/6">
{% include '_types/entry/_options.html' %}
</td>
</tr>

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
{# OOB swap for day confirmed entries nav when entries are edited #}
{% import 'macros/links.html' as links %}
{# Confirmed Entries - vertical on mobile, horizontal with arrows on desktop #}
{% if confirmed_entries %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="day-entries-nav-wrapper"
hx-swap-oob="true">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,
day=day_date.day,
entry_id=entry.id) }}"
class="{{styles.nav_button}}"
>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600 truncate">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
</a>
{% endcall %}
</div>
{% else %}
{# Empty placeholder to remove nav entries when none are confirmed #}
<div id="day-entries-nav-wrapper" hx-swap-oob="true"></div>
{% endif %}

View File

@@ -0,0 +1,25 @@
{% 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('day-header-child', 'day-admin-header-child', '_types/day/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/day/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/day/admin/_main_panel.html' %}
{% endblock %}

View File

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

View File

@@ -0,0 +1,24 @@
{% extends '_types/day/index.html' %}
{% import 'macros/layout.html' as layout %}
{% import 'macros/links.html' as links %}
{% block day_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/day/admin/header/_header.html' import header_row with context %}
{{ header_row() }}
<div id="day-admin-header-child">
{% block day_admin_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/day/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/day/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='day-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.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_select_search,
) %}
<div class="flex gap-1 items-center">
<i class="fa fa-calendar-day"></i>
{{ day_date.strftime('%A %d %B %Y') }}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/day/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends '_types/calendar/index.html' %}
{% block calendar_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('day-header-child', '_types/day/header/_header.html') %}
{% block day_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/day/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/day/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,334 @@
<section id="entry-{{ entry.id }}"
class="{{styles.list_container}}">
<!-- Error container -->
<div id="entry-errors-{{ entry.id }}" class="mt-2 text-sm text-red-600"></div>
<form
class="space-y-3 mt-4"
hx-put="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.put',
slug=post.slug,
calendar_slug=calendar.slug,
day=day, month=month, year=year,
entry_id=entry.id
) }}"
hx-target="#entry-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1"
for="entry-name-{{ entry.id }}">
Name
</label>
<input
id="entry-name-{{ entry.id }}"
name="name"
class="w-full border p-2 rounded"
placeholder="Name"
value="{{ entry.name }}"
>
</div>
<!-- Slot picker -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1"
for="entry-slot-{{ entry.id }}">
Slot
</label>
{% if day_slots %}
<select
id="entry-slot-{{ entry.id }}"
name="slot_id"
class="w-full border p-2 rounded"
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.flexible else '0' }}"
data-cost="{{ slot.cost if slot.cost is not none else '0' }}"
{% if entry.slot_id == slot.id %}selected{% endif %}
>
{{ slot.name }}
({{ slot.time_start.strftime('%H:%M') }}
{% if slot.time_end %}{{ slot.time_end.strftime('%H:%M') }}{% else %}open-ended{% endif %})
{% if slot.flexible %}[flexible]{% endif %}
</option>
{% endfor %}
</select>
{% else %}
<div class="text-sm text-stone-500">
No slots defined for this day.
</div>
{% endif %}
</div>
<!-- Time inputs — shown only for flexible slots -->
<div data-time-fields class="hidden space-y-3">
<div>
<label class="block text-sm font-medium text-stone-700 mb-1"
for="entry-start-{{ entry.id }}">
From
</label>
<input
id="entry-start-{{ entry.id }}"
name="start_at"
type="time"
class="w-full border p-2 rounded"
value="{{ entry.start_at.strftime('%H:%M') if entry.start_at else '' }}"
data-entry-start
>
</div>
<div>
<label class="block text-sm font-medium text-stone-700 mb-1"
for="entry-end-{{ entry.id }}">
To
</label>
<input
id="entry-end-{{ entry.id }}"
name="end_at"
type="time"
class="w-full border p-2 rounded"
value="{{ entry.end_at.strftime('%H:%M') if entry.end_at else '' }}"
data-entry-end
>
</div>
<p class="text-xs text-stone-500" data-slot-boundary></p>
</div>
<!-- Fixed time summary — shown for non-flexible slots -->
<div data-fixed-summary class="hidden text-sm text-stone-600"></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">£{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }}</span>
</div>
<!-- Ticket Configuration -->
<div class="border-t pt-3 mt-3">
<h4 class="text-sm font-semibold text-stone-700 mb-3">Ticket Configuration</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-stone-700 mb-1"
for="entry-ticket-price-{{ entry.id }}">
Ticket Price (£)
</label>
<input
id="entry-ticket-price-{{ entry.id }}"
name="ticket_price"
type="number"
step="0.01"
min="0"
class="w-full border p-2 rounded"
placeholder="Leave empty for no tickets"
value="{{ ('%.2f'|format(entry.ticket_price)) if entry.ticket_price is not none else '' }}"
>
<p class="text-xs text-stone-500 mt-1">Leave empty if no tickets needed</p>
</div>
<div>
<label class="block text-sm font-medium text-stone-700 mb-1"
for="entry-ticket-count-{{ entry.id }}">
Total Tickets
</label>
<input
id="entry-ticket-count-{{ entry.id }}"
name="ticket_count"
type="number"
min="0"
class="w-full border p-2 rounded"
placeholder="Leave empty for unlimited"
value="{{ entry.ticket_count if entry.ticket_count is not none else '' }}"
>
<p class="text-xs text-stone-500 mt-1">Leave empty for unlimited</p>
</div>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<!-- Cancel button -->
<button
type="button"
class="{{ styles.cancel_button }}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=calendar.slug,
day=day, month=month, year=year,
entry_id=entry.id
) }}"
hx-target="#entry-{{ entry.id }}"
hx-swap="outerHTML"
>
Cancel
</button>
<!-- Save button -->
<button
type="submit"
class="{{ styles.action_button }}"
data-confirm="true"
data-confirm-title="Save entry?"
data-confirm-text="Are you sure you want to save this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, save it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save entry
</button>
</div>
</form>
</section>
{# --- 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) {
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();
}
// Initial state
applyFromOption(select.selectedOptions[0]);
select.addEventListener('change', () => {
applyFromOption(select.selectedOptions[0]);
});
// Update cost when times change (for flexible slots)
startInput.addEventListener('input', updateCost);
endInput.addEventListener('input', updateCost);
}
// Initial load
document.addEventListener('DOMContentLoaded', () => {
initEntrySlotPicker(document);
});
// HTMX fragments
if (window.htmx) {
htmx.onLoad((content) => {
initEntrySlotPicker(content);
});
}
})();
</script>

View File

@@ -0,0 +1,126 @@
<section id="entry-{{ entry.id }}" class="{{styles.list_container}}">
<!-- Entry Name -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Name
</div>
<div class="mt-1 text-lg font-medium">
{{ entry.name }}
</div>
</div>
<!-- Slot -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Slot
</div>
<div class="mt-1">
{% if entry.slot %}
<span class="px-2 py-1 rounded text-sm bg-blue-100 text-blue-700">
{{ entry.slot.name }}
</span>
{% if entry.slot.flexible %}
<span class="ml-2 text-xs text-stone-500">(flexible)</span>
{% else %}
<span class="ml-2 text-xs text-stone-500">(fixed)</span>
{% endif %}
{% else %}
<span class="text-sm text-stone-400">No slot assigned</span>
{% endif %}
</div>
</div>
<!-- Time Period -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Time Period
</div>
<div class="mt-1">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %}
{{ entry.end_at.strftime('%H:%M') }}
{% else %}
open-ended
{% endif %}
</div>
</div>
<!-- State -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
State
</div>
<div class="mt-1">
<div id="entry-state-{{entry.id}}">
{% include '_types/entry/_state.html' %}
</div>
</div>
</div>
<!-- Cost -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Cost
</div>
<div class="mt-1">
<span class="font-medium text-green-600">
£{{ ('%.2f'|format(entry.cost)) if entry.cost is not none else '0.00' }}
</span>
</div>
</div>
<!-- Ticket Configuration -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Tickets
</div>
<div class="mt-1" id="entry-tickets-{{entry.id}}">
{% include '_types/entry/_tickets.html' %}
</div>
</div>
<!-- Date -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Date
</div>
<div class="mt-1">
{{ entry.start_at.strftime('%A, %B %d, %Y') }}
</div>
</div>
<!-- Associated Posts -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Associated Posts
</div>
<div class="mt-1" id="entry-posts-{{entry.id}}">
{% include '_types/entry/_posts.html' %}
</div>
</div>
<!-- Options and Edit Button -->
<div class="flex gap-2 mt-6">
{% include '_types/entry/_options.html' %}
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
entry_id=entry.id,
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
) }}"
hx-target="#entry-{{entry.id}}"
hx-swap="outerHTML"
>
Edit
</button>
</div>
</section>

View File

@@ -0,0 +1,40 @@
{% import 'macros/links.html' as links %}
{# Associated Posts - vertical on mobile, horizontal with arrows on desktop #}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entry-posts-nav-wrapper">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a
href="{{ url_for('blog.post.post_detail', slug=entry_post.slug) }}"
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
{% if entry_post.feature_image %}
<img src="{{ entry_post.feature_image }}"
alt="{{ entry_post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry_post.title }}</div>
</div>
</a>
{% endcall %}
</div>
{# Admin link #}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
)
)}}
{% endif %}

View File

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

View File

@@ -0,0 +1,9 @@
{% include '_types/entry/_options.html' %}
<div id="entry-title-{{entry.id}}" hx-swap-oob="innerHTML">
{% include '_types/entry/_title.html' %}
</div>
<div id="entry-state-{{entry.id}}" hx-swap-oob="innerHTML">
{% include '_types/entry/_state.html' %}
</div>

View File

@@ -0,0 +1,98 @@
<div id="calendar_entry_options_{{ entry.id }}" class="flex flex-col md:flex-row gap-1">
{% if entry.state == 'provisional' %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-select="#calendar_entry_options_{{ entry.id }}"
hx-target="#calendar_entry_options_{{entry.id}}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
data-confirm="true"
data-confirm-title="Confirm entry?"
data-confirm-text="Are you sure you want to confirm this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, confirm it"
data-confirm-cancel-text="Cancel"
class="{{styles.action_button}}"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
confirm
</button>
</form>
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-select="#calendar_entry_options_{{ entry.id }}"
hx-target="#calendar_entry_options_{{entry.id}}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
data-confirm="true"
data-confirm-title="Decline entry?"
data-confirm-text="Are you sure you want to decline this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, decine it"
data-confirm-cancel-text="Cancel"
class="{{styles.action_button}}"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
decline
</button>
</form>
{% endif %}
{% if entry.state == 'confirmed' %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-target="#calendar_entry_options_{{ entry.id }}"
hx-select="#calendar_entry_options_{{ entry.id }}"
hx-swap="outerHTML"
hx-trigger="confirmed"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="button"
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Provisional entry?"
data-confirm-text="Are you sure you want to provisional this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, provisional it"
data-confirm-cancel-text="Cancel"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
provisional
</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,107 @@
{% for search_post in search_posts %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.add_post',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-target="#entry-posts-{{entry.id}}"
hx-swap="innerHTML"
class="p-2 hover:bg-stone-50 cursor-pointer rounded text-sm border-b"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="post_id" value="{{ search_post.id }}" />
<button
type="submit"
class="w-full text-left flex items-center gap-2"
data-confirm
data-confirm-title="Add post?"
data-confirm-text="Add {{ search_post.title }} to this entry?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
>
{% if search_post.feature_image %}
<img src="{{ search_post.feature_image }}"
alt="{{ search_post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ search_post.title }}</span>
</button>
</form>
{% endfor %}
{# Infinite scroll sentinel #}
{% if page < total_pages|int %}
<div
id="post-search-sentinel-{{ page }}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id,
q=search_query,
page=page + 1
) }}"
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 sentinel:retry
remove .hidden from .js-loading in me
add .hidden to .js-neterr in me
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
trigger htmx:consume on me
call htmx.trigger(me, 'intersect')
end
def backoff()
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
set myMs to Number(me.dataset.retryMs)
if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end
js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs)
end
on htmx:beforeRequest
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
end
on htmx:afterSwap
set me.dataset.retryMs to 1000
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
class="py-2"
>
<div class="text-xs text-center text-stone-400 js-loading">
Loading more...
</div>
<div class="text-xs text-center text-stone-400 js-neterr hidden">
Connection error. Retrying...
</div>
</div>
{% elif search_posts %}
<div class="py-2 text-xs text-center text-stone-400">
End of results
</div>
{% endif %}

View File

@@ -0,0 +1,74 @@
<!-- Associated Posts Section -->
<div class="space-y-2">
{% if entry_posts %}
<div class="space-y-2">
{% for entry_post in entry_posts %}
<div class="flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border">
{% if entry_post.feature_image %}
<img src="{{ entry_post.feature_image }}"
alt="{{ entry_post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span class="text-sm flex-1">{{ entry_post.title }}</span>
<button
type="button"
class="text-xs text-red-600 hover:text-red-800 flex-shrink-0"
data-confirm
data-confirm-title="Remove post?"
data-confirm-text="This will remove {{ entry_post.title }} from this entry"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id,
post_id=entry_post.id
) }}"
hx-trigger="confirmed"
hx-target="#entry-posts-{{entry.id}}"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
>
<i class="fa fa-times"></i> Remove
</button>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-400">No posts associated</p>
{% endif %}
<!-- Search to add posts -->
<div class="mt-3 pt-3 border-t">
<label class="block text-xs font-medium text-stone-700 mb-1">
Add Post
</label>
<input
type="text"
placeholder="Search posts..."
class="w-full px-3 py-2 border rounded text-sm"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
) }}"
hx-trigger="keyup changed delay:300ms, load"
hx-target="#post-search-results-{{entry.id}}"
hx-swap="innerHTML"
name="q"
/>
<div id="post-search-results-{{entry.id}}" class="mt-2 max-h-96 overflow-y-auto border rounded"></div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
{% if entry.state %}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if entry.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif entry.state == 'provisional' %}
bg-amber-100 text-amber-800
{% elif entry.state == 'ordered' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ entry.state|capitalize }}
</span>
{% endif %}

View File

@@ -0,0 +1,105 @@
{% if entry.ticket_price is not none %}
{# Tickets are configured #}
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-stone-700">Price:</span>
<span class="font-medium text-green-600">
£{{ ('%.2f'|format(entry.ticket_price)) }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-stone-700">Available:</span>
<span class="font-medium text-blue-600">
{% if entry.ticket_count is not none %}
{{ entry.ticket_count }} tickets
{% else %}
Unlimited
{% endif %}
</span>
</div>
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-800 underline"
onclick="document.getElementById('ticket-form-{{entry.id}}').classList.remove('hidden'); this.classList.add('hidden');"
>
Edit ticket config
</button>
</div>
{% else %}
{# No tickets configured #}
<div class="space-y-2">
<span class="text-sm text-stone-400">No tickets configured</span>
<button
type="button"
class="block text-xs text-blue-600 hover:text-blue-800 underline"
onclick="document.getElementById('ticket-form-{{entry.id}}').classList.remove('hidden'); this.classList.add('hidden');"
>
Configure tickets
</button>
</div>
{% endif %}
{# Ticket configuration form (hidden by default) #}
<form
id="ticket-form-{{entry.id}}"
class="{% if entry.ticket_price is not none %}hidden{% endif %} space-y-3 mt-2 p-3 border rounded bg-stone-50"
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.update_tickets',
entry_id=entry.id,
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
) }}"
hx-target="#entry-tickets-{{entry.id}}"
hx-swap="innerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div>
<label for="ticket-price-{{entry.id}}" class="block text-sm font-medium text-stone-700 mb-1">
Ticket Price (£)
</label>
<input
type="number"
id="ticket-price-{{entry.id}}"
name="ticket_price"
step="0.01"
min="0"
value="{{ ('%.2f'|format(entry.ticket_price)) if entry.ticket_price is not none else '' }}"
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., 5.00"
/>
</div>
<div>
<label for="ticket-count-{{entry.id}}" class="block text-sm font-medium text-stone-700 mb-1">
Total Tickets
</label>
<input
type="number"
id="ticket-count-{{entry.id}}"
name="ticket_count"
min="0"
value="{{ entry.ticket_count if entry.ticket_count is not none else '' }}"
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Leave empty for unlimited"
/>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
>
Save
</button>
<button
type="button"
class="px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"
onclick="document.getElementById('ticket-form-{{entry.id}}').classList.add('hidden'); document.getElementById('entry-tickets-{{entry.id}}').querySelectorAll('button:not([type=submit])').forEach(btn => btn.classList.remove('hidden'));"
>
Cancel
</button>
</div>
</form>

View File

@@ -0,0 +1,5 @@
{% from 'macros/date.html' import t %}
<div class="text-sm text-gray-600">
{{ t(entry.start_at) }}
{% if entry.end_at %} → {{ t(entry.end_at) }}{% endif %}
</div>

View File

@@ -0,0 +1,3 @@
<i class="fa fa-clock"></i>
{{ entry.name }}
{% include '_types/entry/_state.html' %}

View File

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

View File

@@ -0,0 +1,18 @@
{% import 'macros/links.html' as links %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day
),
hx_select_search,
select_colours,
True,
aclass=styles.nav_button,
)%}
ticket_types
{% endcall %}

View File

@@ -0,0 +1,31 @@
{# OOB swap for entry posts nav when posts are associated/disassociated #}
{% import 'macros/links.html' as links %}
{# Associated Posts - vertical on mobile, horizontal with arrows on desktop #}
{% if entry_posts %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entry-posts-nav-wrapper"
hx-swap-oob="true">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a
href="{{ url_for('blog.post.post_detail', slug=entry_post.slug) }}"
class="{{styles.nav_button}}"
>
{% if entry_post.feature_image %}
<img src="{{ entry_post.feature_image }}"
alt="{{ entry_post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry_post.title }}</div>
</div>
</a>
{% endcall %}
</div>
{% else %}
{# Empty placeholder to remove nav posts when all are disassociated #}
<div id="entry-posts-nav-wrapper" hx-swap-oob="true"></div>
{% endif %}

View File

@@ -0,0 +1,25 @@
{% 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('entry-header-child', 'entry-admin-header-child', '_types/entry/admin/header/_header.html')}}
{% from '_types/entry/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% include '_types/entry/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/entry/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='entry-admin-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
),
hx_select_search
) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/entry/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,24 @@
{% extends '_types/entry/index.html' %}
{% import 'macros/layout.html' as layout %}
{% import 'macros/links.html' as links %}
{% block entry_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/entry/admin/header/_header.html' import header_row with context %}
{{ header_row() }}
<div id="entry-admin-header-child">
{% block entry_admin_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/entry/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/entry/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='entry-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,
year=year,
entry_id=entry.id
),
hx_select_search,
) %}
<div id="entry-title-{{entry.id}}" class="flex gap-1 items-center">
{% include '_types/entry/_title.html' %}
{% include '_types/entry/_times.html' %}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/entry/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/day/index.html' %}
{% block day_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('entry-header-child', '_types/entry/header/_header.html') %}
{% block entry_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/entry/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/entry/_main_panel.html' %}
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
{% macro description(slot, oob=False) %}
<div
id="slot-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"
>
{{ slot.description or ''}}
</div>
{% endmacro %}

View File

@@ -0,0 +1,5 @@
<p class="text-stone-500 whitespace-pre-line break-all w-full">
{% if slot.description %}
{{ slot.description }}
{% endif %}
</p>

View File

@@ -0,0 +1,182 @@
<section id="slot-{{ slot.id }}" class="{{styles.list_container}}">
<!-- Quick-edit form -->
<div id="slot-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="space-y-3 mt-4"
hx-put="{{ url_for('blog.post.calendars.calendar.slots.slot.put',
slug=post.slug,
calendar_slug=calendar.slug,
slot_id=slot.id) }}"
hx-target="#slot-{{ slot.id }}"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-name-{{ slot.id }}">
Name
</label>
<input
id="slot-name-{{ slot.id }}"
name="name"
placeholder="Name"
class="w-full border p-2 rounded"
value="{{ slot.name }}"
/>
</div>
<!-- Cost -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-cost-{{ slot.id }}">
Cost
</label>
<input
id="slot-cost-{{ slot.id }}"
name="cost"
placeholder="Cost e.g. 12.50"
class="w-full border p-2 rounded"
value="{{ '%.2f'|format(slot.cost) if slot.cost is not none else '' }}"
/>
</div>
<!-- Time start -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-start-{{ slot.id }}">
Start time
</label>
<input
id="slot-start-{{ slot.id }}"
name="time_start"
placeholder="Start HH:MM"
class="w-full border p-2 rounded"
value="{{ slot.time_start.strftime('%H:%M') if slot.time_start else '' }}"
/>
</div>
<!-- Time end -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-end-{{ slot.id }}">
End time
</label>
<input
id="slot-end-{{ slot.id }}"
name="time_end"
placeholder="End HH:MM"
class="w-full border p-2 rounded"
value="{{ slot.time_end.strftime('%H:%M') if slot.time_end else '' }}"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-desc-{{ slot.id }}">
Description
</label>
<textarea
id="slot-desc-{{ slot.id }}"
name="description"
rows="2"
placeholder="Description"
class="w-full border p-2 rounded"
>{{ slot.description or '' }}</textarea>
</div>
<!-- Days -->
<div>
<span class="block text-sm font-medium text-stone-700 mb-1">
Days
</span>
{# pre-check "All" if every day is true on this slot #}
{% set all_days_checked =
slot|getattr('mon')
and slot|getattr('tue')
and slot|getattr('wed')
and slot|getattr('thu')
and slot|getattr('fri')
and slot|getattr('sat')
and slot|getattr('sun') %}
<div
class="flex flex-wrap gap-3 items-center text-sm"
data-days-group
>
{# "All" toggle no name so its not submitted #}
<label class="flex items-center gap-1 px-2 py-1 rounded-full bg-slate-200">
<input
type="checkbox"
data-day-all
{% if all_days_checked %}checked{% endif %}
/>
<span>All</span>
</label>
{# Individual days, with data-day like the add form #}
{% for key, label in [
('mon','Mon'),('tue','Tue'),('wed','Wed'),('thu','Thu'),
('fri','Fri'),('sat','Sat'),('sun','Sun')
] %}
{% set is_checked = slot|getattr(key) %}
<label class="flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100">
<input
type="checkbox"
name="{{ key }}"
value="1"
data-day="{{ key }}"
{% if is_checked %}checked{% endif %}
/>
<span>{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- NEW: Flexible flag -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="slot-flexible-{{ slot.id }}">
Flexible booking
</label>
<label class="inline-flex items-center gap-2 text-xs">
<input
id="slot-flexible-{{ slot.id }}"
type="checkbox"
name="flexible"
value="1"
{% if slot.flexible %}checked{% endif %}
>
<span>Allow bookings at any time within this band</span>
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.slot.get_view',
slug=post.slug,
calendar_slug=calendar.slug,
slot_id=slot.id) }}"
hx-target="#slot-{{ slot.id }}"
hx-swap="outerHTML"
>
Cancel
</button>
<button
type="submit"
class="{{ styles.action_button }}"
data-confirm="true"
data-confirm-title="Save slot?"
data-confirm-text="Are you sure you want to save this slot?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, save it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save slot
</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,73 @@
<section id="slot-{{slot.id}}" class="{{styles.list_container}}">
<!-- Days -->
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Days
</div>
<div class="mt-1">
{% set days = slot.days_display.split(', ') %}
{% if days and days[0] != "—" %}
<div class="flex flex-wrap gap-1">
{% for day in days %}
<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">
{{ day }}
</span>
{% endfor %}
</div>
{% else %}
<span class="text-xs text-slate-400">No days</span>
{% endif %}
</div>
</div>
<!-- Flexible -->
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Flexible
</div>
<div class="mt-1">
{{ 'yes' if slot.flexible else 'no' }}
</div>
</div>
<!-- Time & Cost (still "up-down" per field, but can sit side-by-side on wide screens) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Time
</div>
<div class="mt-1">
{{ slot.time_start.strftime('%H:%M') }} — {{ slot.time_end.strftime('%H:%M') }}
</div>
</div>
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Cost
</div>
<div class="mt-1">
{{ ('%.2f'|format(slot.cost)) if slot.cost is not none else '' }}
</div>
</div>
</div>
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.slots.slot.get_edit',
slot_id=slot.id,
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#slot-{{slot.id}}"
hx-swap="outerHTML"
>
Edit
</button>
</section>
{% if oob %}
{% from '_types/slot/__description.html' import description %}
{{description(slot, oob=True)}}
{% endif %}

View File

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

View File

@@ -0,0 +1,26 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='slot-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=slot.id),
hx_select_search,
) %}
<div class="flex flex-col md:flex-row md:gap-2 items-center">
<div class="flex flex-row items-center gap-2">
<i class="fa fa-clock"></i>
<div class="shrink-0">
{{ slot.name }}
</div>
</div>
{% from '_types/slot/__description.html' import description %}
{{description(slot)}}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{#% include '_types/slot/_nav.html' %#}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/slots/index.html' %}
{% import 'macros/layout.html' as layout %}
{% block slots_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('slot-header-child', '_types/slot/header/_header.html') %}
{% block slot_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{#% include '_types/slot/_nav.html' %#}
{% endblock %}
{% block content %}
{% include '_types/slot/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,125 @@
<form
hx-post="{{ url_for('blog.post.calendars.calendar.slots.post',
slug=post.slug,
calendar_slug=calendar.slug) }}"
hx-target="#slots-table"
hx-select="#slots-table"
hx-disinherit="hx-select"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="space-y-3"
>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="md:col-span-2">
<label class="block text-xs font-semibold mb-1">Name</label>
<input
type="text"
name="name"
class="w-full border rounded px-2 py-1 text-sm"
required
>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold mb-1">Description</label>
<input
type="text"
name="description"
class="w-full border rounded px-2 py-1 text-sm"
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Days</label>
<div class="flex flex-wrap gap-1 text-xs" data-days-group>
{# "All" toggle no name so its not submitted #}
<label class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-200">
<input type="checkbox" data-day-all>
<span>All</span>
</label>
{# Individual days #}
{% for key, label in [
('mon','Mon'),('tue','Tue'),('wed','Wed'),('thu','Thu'),
('fri','Fri'),('sat','Sat'),('sun','Sun')
] %}
<label class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100">
<input type="checkbox" name="{{ key }}" value="1" data-day="{{ key }}">
<span>{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Time start</label>
<input
type="time"
name="time_start"
class="w-full border rounded px-2 py-1 text-sm"
required
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Time end</label>
<input
type="time"
name="time_end"
class="w-full border rounded px-2 py-1 text-sm"
required
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Cost</label>
<input
type="text"
name="cost"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="e.g. 5.00"
>
</div>
{# NEW: flexible flag #}
<div class="md:col-span-2">
<label class="block text-xs font-semibold mb-1">Flexible booking</label>
<label class="inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
name="flexible"
value="1"
>
<span>Allow bookings at any time within this band</span>
</label>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_button',
slug=post.slug,
calendar_slug=calendar.slug) }}"
hx-target="#slot-add-container"
hx-swap="innerHTML"
>
Cancel
</button>
<button
type="submit"
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Add slot?"
data-confirm-text="Are you sure you want to add this slot?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save slot
</button>
</div>
</form>

View File

@@ -0,0 +1,12 @@
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_form',
slug=post.slug,
calendar_slug=calendar.slug) }}"
hx-target="#slot-add-container"
hx-swap="innerHTML"
>
+ Add slot
</button>

View File

@@ -0,0 +1,26 @@
<section id="slots-table" class="{{styles.list_container}}">
<table class="w-full text-sm border table-fixed">
<thead class="bg-stone-100">
<tr>
<th class="p-2 text-left w-1/6">Name</th>
<th class="p-2 text-left w-1/6">Flexible</th>
<th class="text-left p-2 w-1/6">Days</th>
<th class="text-left p-1/6">Time</th>
<th class="text-left p-2 w-1/6">Cost</th>
<th class="text-left p-2 w-1/6">Actions</th>
</tr>
</thead>
<tbody>
{% for s in slots %}
{% include '_types/slots/_row.html' %}
{% else %}
<tr><td colspan="5" class="p-3 text-stone-500">No slots yet.</td></tr>
{% endfor %}
</tbody>
</table>
<!-- This is what HTMX will swap between button and form -->
<div id="slot-add-container" class="mt-4">
{% include '_types/slots/_add_button.html' %}
</div>
</section>

View File

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

View File

@@ -0,0 +1,63 @@
{% import 'macros/links.html' as links %}
<tr class="{{ styles.tr }}">
<td class="p-2 align-top w-1/6">
<div class="font-medium">
{% call links.link(
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=s.id),
hx_select_search,
aclass=styles.pill
) %}
{{ s.name }}
{% endcall %}
</div>
{% set slot = s %}
{% include '_types/slot/_description.html' %}
</td>
<td class="p-2 align-top w-1/6">
{{ 'yes' if s.flexible else 'no' }}
</td>
<td class="p-2 align-top w-1/6">
{% set days = s.days_display.split(', ') %}
{% if days and days[0] != "—" %}
<div class="flex flex-wrap gap-1">
{% for day in days %}
<span class="px-2 py-0.5 rounded-full text-xs bg-slate-200">
{{ day }}
</span>
{% endfor %}
</div>
{% else %}
<span class="text-xs text-slate-400">No days</span>
{% endif %}
</td>
<td class="p-2 align-top w-1/6">
{{ s.time_start.strftime('%H:%M') }} - {{ s.time_end.strftime('%H:%M') }}
</td>
<td class="p-2 align-top w-1/6">
{{ ('%.2f'|format(s.cost)) if s.cost is not none else '' }}
</td>
<td class="p-2 align-top w-1/6">
<button
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Delete slot?"
data-confirm-text="This action cannot be undone."
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('blog.post.calendars.calendar.slots.slot.slot_delete',
slug=post.slug,
calendar_slug=calendar.slug,
slot_id=s.id) }}"
hx-target="#slots-table"
hx-select="#slots-table"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-trigger="confirmed"
type="button"
>
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>

View File

@@ -0,0 +1,19 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='slots-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug= calendar.slug),
hx_select_search,
) %}
<i class="fa fa-clock"></i>
<div class="shrink-0">
slots
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,19 @@
{% extends '_types/calendar/index.html' %}
{% block calendar_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('slots-header-child', '_types/slots/header/_header.html') %}
{% block slots_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{#% include '_types/calendar/_nav.html' %#}
{% endblock %}
{% block content %}
{% include '_types/slots/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,103 @@
<section id="ticket-{{ ticket_type.id }}" class="{{styles.list_container}}">
<!-- Quick-edit form -->
<div id="ticket-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="space-y-3 mt-4"
hx-put="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
slug=post.slug,
calendar_slug=calendar.slug,
year=year,
month=month,
day=day,
entry_id=entry.id,
ticket_type_id=ticket_type.id) }}"
hx-target="#ticket-{{ ticket_type.id }}"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="ticket-name-{{ ticket_type.id }}">
Name
</label>
<input
id="ticket-name-{{ ticket_type.id }}"
name="name"
placeholder="e.g. Adult, Child, Student"
class="w-full border p-2 rounded"
value="{{ ticket_type.name }}"
required
/>
</div>
<!-- Cost -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="ticket-cost-{{ ticket_type.id }}">
Cost (£)
</label>
<input
id="ticket-cost-{{ ticket_type.id }}"
name="cost"
type="number"
step="0.01"
min="0"
placeholder="e.g. 5.00"
class="w-full border p-2 rounded"
value="{{ '%.2f'|format(ticket_type.cost) if ticket_type.cost is not none else '' }}"
required
/>
</div>
<!-- Count -->
<div>
<label class="block text-sm font-medium text-stone-700 mb-1" for="ticket-count-{{ ticket_type.id }}">
Count
</label>
<input
id="ticket-count-{{ ticket_type.id }}"
name="count"
type="number"
min="0"
placeholder="e.g. 50"
class="w-full border p-2 rounded"
value="{{ ticket_type.count }}"
required
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day,
ticket_type_id=ticket_type.id) }}"
hx-target="#ticket-{{ ticket_type.id }}"
hx-swap="outerHTML"
>
Cancel
</button>
<button
type="submit"
class="{{ styles.action_button }}"
data-confirm="true"
data-confirm-title="Save ticket type?"
data-confirm-text="Are you sure you want to save this ticket type?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, save it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save ticket type
</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,50 @@
<section id="ticket-{{ticket_type.id}}" class="{{styles.list_container}}">
<!-- Name, Cost, and Count in a grid -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Name
</div>
<div class="mt-1">
{{ ticket_type.name }}
</div>
</div>
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Cost
</div>
<div class="mt-1">
£{{ ('%.2f'|format(ticket_type.cost)) if ticket_type.cost is not none else '0.00' }}
</div>
</div>
<div class="flex flex-col">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
Count
</div>
<div class="mt-1">
{{ ticket_type.count }}
</div>
</div>
</div>
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit',
ticket_type_id=ticket_type.id,
slug=post.slug,
calendar_slug=calendar.slug,
year=year,
month=month,
day=day,
entry_id=entry.id,
) }}"
hx-target="#ticket-{{ticket_type.id}}"
hx-swap="outerHTML"
>
Edit
</button>
</section>

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='ticket_type-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=year,
month=month,
day=day,
entry_id=entry.id,
ticket_type_id=ticket_type.id
),
hx_select_search,
) %}
<div class="flex flex-col md:flex-row md:gap-2 items-center">
<div class="flex flex-row items-center gap-2">
<i class="fa fa-ticket"></i>
<div class="shrink-0">
{{ ticket_type.name }}
</div>
</div>
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/ticket_type/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,19 @@
{% extends '_types/ticket_types/index.html' %}
{% import 'macros/layout.html' as layout %}
{% block ticket_types_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('ticket_types-header-child', '_types/ticket_type/header/_header.html') %}
{% block ticket_type_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{#% include '_types/ticket_type/_nav.html' %#}
{% endblock %}
{% block content %}
{% include '_types/ticket_type/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,87 @@
<form
hx-post="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day,
) }}"
hx-target="#tickets-table"
hx-select="#tickets-table"
hx-disinherit="hx-select"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="space-y-3"
>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-semibold mb-1">Name</label>
<input
type="text"
name="name"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="e.g. Adult, Child, Student"
required
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Cost (£)</label>
<input
type="number"
name="cost"
step="0.01"
min="0"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="e.g. 5.00"
required
>
</div>
<div>
<label class="block text-xs font-semibold mb-1">Count</label>
<input
type="number"
name="count"
min="0"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="e.g. 50"
required
>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day,
) }}"
hx-target="#ticket-add-container"
hx-swap="innerHTML"
>
Cancel
</button>
<button
type="submit"
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Add ticket type?"
data-confirm-text="Are you sure you want to add this ticket type?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
>
<i class="fa fa-save"></i>
Save ticket type
</button>
</div>
</form>

View File

@@ -0,0 +1,16 @@
<button
class="{{styles.action_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day,
) }}"
hx-target="#ticket-add-container"
hx-swap="innerHTML"
>
<i class="fa fa-plus"></i>
Add ticket type
</button>

View File

@@ -0,0 +1,24 @@
<section id="tickets-table" class="{{styles.list_container}}">
<table class="w-full text-sm border table-fixed">
<thead class="bg-stone-100">
<tr>
<th class="p-2 text-left w-1/3">Name</th>
<th class="text-left p-2 w-1/4">Cost</th>
<th class="text-left p-2 w-1/4">Count</th>
<th class="text-left p-2 w-1/6">Actions</th>
</tr>
</thead>
<tbody>
{% for tt in ticket_types %}
{% include '_types/ticket_types/_row.html' %}
{% else %}
<tr><td colspan="4" class="p-3 text-stone-500">No ticket types yet.</td></tr>
{% endfor %}
</tbody>
</table>
<!-- This is what HTMX will swap between button and form -->
<div id="ticket-add-container" class="mt-4">
{% include '_types/ticket_types/_add_button.html' %}
</div>
</section>

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
{% import 'macros/links.html' as links %}
<tr class="{{ styles.tr }}">
<td class="p-2 align-top w-1/3">
<div class="font-medium">
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=year,
month=month,
day=day,
entry_id=entry.id,
ticket_type_id=tt.id
),
hx_select_search,
aclass=styles.pill
) %}
{{ tt.name }}
{% endcall %}
</div>
</td>
<td class="p-2 align-top w-1/4">
£{{ ('%.2f'|format(tt.cost)) if tt.cost is not none else '0.00' }}
</td>
<td class="p-2 align-top w-1/4">
{{ tt.count }}
</td>
<td class="p-2 align-top w-1/6">
<button
class="{{styles.action_button}}"
data-confirm="true"
data-confirm-title="Delete ticket type?"
data-confirm-text="This action cannot be undone."
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
slug=post.slug,
calendar_slug=calendar.slug,
year=year,
month=month,
day=day,
entry_id=entry.id,
ticket_type_id=tt.id) }}"
hx-target="#tickets-table"
hx-select="#tickets-table"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-trigger="confirmed"
type="button"
>
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>

View File

@@ -0,0 +1,25 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='ticket_types-row', oob=oob) %}
{% call links.link(url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
slug=post.slug,
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
month=month,
day=day
), hx_select_search) %}
<i class="fa fa-ticket"></i>
<div class="shrink-0">
ticket types
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/ticket_types/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/entry/admin/index.html' %}
{% block entry_admin_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('ticket_type-header-child', '_types/ticket_types/header/_header.html') %}
{% block ticket_types_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/ticket_types/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/ticket_types/_main_panel.html' %}
{% endblock %}