feat: initialize events app with calendars, slots, tickets, and internal API
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
12
templates/_types/calendar/_description.html
Normal file
12
templates/_types/calendar/_description.html
Normal 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 %}
|
||||
180
templates/_types/calendar/_main_panel.html
Normal file
180
templates/_types/calendar/_main_panel.html
Normal 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"
|
||||
>
|
||||
«
|
||||
</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"
|
||||
>
|
||||
‹
|
||||
</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"
|
||||
>
|
||||
›
|
||||
</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"
|
||||
>
|
||||
»
|
||||
</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>
|
||||
17
templates/_types/calendar/_nav.html
Normal file
17
templates/_types/calendar/_nav.html
Normal 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 %}
|
||||
22
templates/_types/calendar/_oob_elements.html
Normal file
22
templates/_types/calendar/_oob_elements.html
Normal 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 %}
|
||||
33
templates/_types/calendar/admin/_description.html
Normal file
33
templates/_types/calendar/admin/_description.html
Normal 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 %}
|
||||
|
||||
|
||||
43
templates/_types/calendar/admin/_description_edit.html
Normal file
43
templates/_types/calendar/admin/_description_edit.html
Normal 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>
|
||||
46
templates/_types/calendar/admin/_main_panel.html
Normal file
46
templates/_types/calendar/admin/_main_panel.html
Normal 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>
|
||||
2
templates/_types/calendar/admin/_nav.html
Normal file
2
templates/_types/calendar/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
25
templates/_types/calendar/admin/_oob_elements.html
Normal file
25
templates/_types/calendar/admin/_oob_elements.html
Normal 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 %}
|
||||
14
templates/_types/calendar/admin/header/_header.html
Normal file
14
templates/_types/calendar/admin/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='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 %}
|
||||
24
templates/_types/calendar/admin/index.html
Normal file
24
templates/_types/calendar/admin/index.html
Normal 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 %}
|
||||
23
templates/_types/calendar/header/_header.html
Normal file
23
templates/_types/calendar/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
20
templates/_types/calendar/index.html
Normal file
20
templates/_types/calendar/index.html
Normal 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 %}
|
||||
44
templates/_types/calendars/_calendars_list.html
Normal file
44
templates/_types/calendars/_calendars_list.html
Normal 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 %}
|
||||
27
templates/_types/calendars/_main_panel.html
Normal file
27
templates/_types/calendars/_main_panel.html
Normal 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>
|
||||
2
templates/_types/calendars/_nav.html
Normal file
2
templates/_types/calendars/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
28
templates/_types/calendars/_oob_elements.html
Normal file
28
templates/_types/calendars/_oob_elements.html
Normal 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 %}
|
||||
|
||||
|
||||
14
templates/_types/calendars/header/_header.html
Normal file
14
templates/_types/calendars/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='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 %}
|
||||
22
templates/_types/calendars/index.html
Normal file
22
templates/_types/calendars/index.html
Normal 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 %}
|
||||
301
templates/_types/day/_add.html
Normal file
301
templates/_types/day/_add.html
Normal 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>
|
||||
17
templates/_types/day/_add_button.html
Normal file
17
templates/_types/day/_add_button.html
Normal 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>
|
||||
28
templates/_types/day/_main_panel.html
Normal file
28
templates/_types/day/_main_panel.html
Normal 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>
|
||||
41
templates/_types/day/_nav.html
Normal file
41
templates/_types/day/_nav.html
Normal 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 %}
|
||||
18
templates/_types/day/_oob_elements.html
Normal file
18
templates/_types/day/_oob_elements.html
Normal 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 %}
|
||||
76
templates/_types/day/_row.html
Normal file
76
templates/_types/day/_row.html
Normal 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>
|
||||
2
templates/_types/day/admin/_main_panel.html
Normal file
2
templates/_types/day/admin/_main_panel.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
2
templates/_types/day/admin/_nav.html
Normal file
2
templates/_types/day/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
34
templates/_types/day/admin/_nav_entries_oob.html
Normal file
34
templates/_types/day/admin/_nav_entries_oob.html
Normal 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 %}
|
||||
25
templates/_types/day/admin/_oob_elements.html
Normal file
25
templates/_types/day/admin/_oob_elements.html
Normal 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 %}
|
||||
21
templates/_types/day/admin/header/_header.html
Normal file
21
templates/_types/day/admin/header/_header.html
Normal 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 %}
|
||||
24
templates/_types/day/admin/index.html
Normal file
24
templates/_types/day/admin/index.html
Normal 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 %}
|
||||
27
templates/_types/day/header/_header.html
Normal file
27
templates/_types/day/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
18
templates/_types/day/index.html
Normal file
18
templates/_types/day/index.html
Normal 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 %}
|
||||
334
templates/_types/entry/_edit.html
Normal file
334
templates/_types/entry/_edit.html
Normal 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>
|
||||
126
templates/_types/entry/_main_panel.html
Normal file
126
templates/_types/entry/_main_panel.html
Normal 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>
|
||||
40
templates/_types/entry/_nav.html
Normal file
40
templates/_types/entry/_nav.html
Normal 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 %}
|
||||
18
templates/_types/entry/_oob_elements.html
Normal file
18
templates/_types/entry/_oob_elements.html
Normal 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 %}
|
||||
9
templates/_types/entry/_optioned.html
Normal file
9
templates/_types/entry/_optioned.html
Normal 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>
|
||||
98
templates/_types/entry/_options.html
Normal file
98
templates/_types/entry/_options.html
Normal 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>
|
||||
107
templates/_types/entry/_post_search_results.html
Normal file
107
templates/_types/entry/_post_search_results.html
Normal 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 %}
|
||||
74
templates/_types/entry/_posts.html
Normal file
74
templates/_types/entry/_posts.html
Normal 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>
|
||||
15
templates/_types/entry/_state.html
Normal file
15
templates/_types/entry/_state.html
Normal 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 %}
|
||||
105
templates/_types/entry/_tickets.html
Normal file
105
templates/_types/entry/_tickets.html
Normal 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>
|
||||
5
templates/_types/entry/_times.html
Normal file
5
templates/_types/entry/_times.html
Normal 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>
|
||||
3
templates/_types/entry/_title.html
Normal file
3
templates/_types/entry/_title.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<i class="fa fa-clock"></i>
|
||||
{{ entry.name }}
|
||||
{% include '_types/entry/_state.html' %}
|
||||
2
templates/_types/entry/admin/_main_panel.html
Normal file
2
templates/_types/entry/admin/_main_panel.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
18
templates/_types/entry/admin/_nav.html
Normal file
18
templates/_types/entry/admin/_nav.html
Normal 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 %}
|
||||
31
templates/_types/entry/admin/_nav_posts_oob.html
Normal file
31
templates/_types/entry/admin/_nav_posts_oob.html
Normal 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 %}
|
||||
25
templates/_types/entry/admin/_oob_elements.html
Normal file
25
templates/_types/entry/admin/_oob_elements.html
Normal 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 %}
|
||||
22
templates/_types/entry/admin/header/_header.html
Normal file
22
templates/_types/entry/admin/header/_header.html
Normal 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 %}
|
||||
24
templates/_types/entry/admin/index.html
Normal file
24
templates/_types/entry/admin/index.html
Normal 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 %}
|
||||
28
templates/_types/entry/header/_header.html
Normal file
28
templates/_types/entry/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
20
templates/_types/entry/index.html
Normal file
20
templates/_types/entry/index.html
Normal 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 %}
|
||||
48
templates/_types/post_entries/_main_panel.html
Normal file
48
templates/_types/post_entries/_main_panel.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<div id="post-entries-content" class="space-y-6 p-4">
|
||||
|
||||
{# Associated Entries List #}
|
||||
{% include '_types/post/admin/_associated_entries.html' %}
|
||||
|
||||
{# Calendars Browser #}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-lg font-semibold">Browse Calendars</h3>
|
||||
{% for calendar in all_calendars %}
|
||||
<details class="border rounded-lg bg-white"
|
||||
_="on toggle
|
||||
if my.open
|
||||
for other in <details[open]/>
|
||||
if other is not me
|
||||
set other.open to false
|
||||
end
|
||||
end
|
||||
end">
|
||||
<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">
|
||||
{% if calendar.post.feature_image %}
|
||||
<img src="{{ calendar.post.feature_image }}"
|
||||
alt="{{ calendar.post.title }}"
|
||||
class="w-12 h-12 rounded object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold flex items-center gap-2">
|
||||
<i class="fa fa-calendar text-stone-500"></i>
|
||||
{{ calendar.name }}
|
||||
</div>
|
||||
<div class="text-sm text-stone-600">
|
||||
{{ calendar.post.title }}
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-4 border-t"
|
||||
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
|
||||
hx-trigger="intersect once"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-sm text-stone-400">Loading calendar...</div>
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="text-sm text-stone-400">No calendars found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
2
templates/_types/post_entries/_nav.html
Normal file
2
templates/_types/post_entries/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
28
templates/_types/post_entries/_oob_elements.html
Normal file
28
templates/_types/post_entries/_oob_elements.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('post-admin-header-child', 'post_entries-header-child', '_types/post_entries/header/_header.html')}}
|
||||
|
||||
{% from '_types/post/admin/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/post_entries/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/post_entries/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
17
templates/_types/post_entries/header/_header.html
Normal file
17
templates/_types/post_entries/header/_header.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_entries-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||
<div>
|
||||
entries
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/post_entries/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
19
templates/_types/post_entries/index.html
Normal file
19
templates/_types/post_entries/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends '_types/post/admin/index.html' %}
|
||||
|
||||
|
||||
|
||||
{% block post_admin_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('post-admin-header-child', '_types/post_entries/header/_header.html') %}
|
||||
{% block post_entries_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/post_entries/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post_entries/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
13
templates/_types/slot/__description.html
Normal file
13
templates/_types/slot/__description.html
Normal 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 %}
|
||||
5
templates/_types/slot/_description.html
Normal file
5
templates/_types/slot/_description.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<p class="text-stone-500 whitespace-pre-line break-all w-full">
|
||||
{% if slot.description %}
|
||||
{{ slot.description }}
|
||||
{% endif %}
|
||||
</p>
|
||||
182
templates/_types/slot/_edit.html
Normal file
182
templates/_types/slot/_edit.html
Normal 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 it’s 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>
|
||||
73
templates/_types/slot/_main_panel.html
Normal file
73
templates/_types/slot/_main_panel.html
Normal 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 %}
|
||||
15
templates/_types/slot/_oob_elements.html
Normal file
15
templates/_types/slot/_oob_elements.html
Normal 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 %}
|
||||
26
templates/_types/slot/header/_header.html
Normal file
26
templates/_types/slot/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
20
templates/_types/slot/index.html
Normal file
20
templates/_types/slot/index.html
Normal 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 %}
|
||||
125
templates/_types/slots/_add.html
Normal file
125
templates/_types/slots/_add.html
Normal 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 it’s 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>
|
||||
12
templates/_types/slots/_add_button.html
Normal file
12
templates/_types/slots/_add_button.html
Normal 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>
|
||||
26
templates/_types/slots/_main_panel.html
Normal file
26
templates/_types/slots/_main_panel.html
Normal 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>
|
||||
15
templates/_types/slots/_oob_elements.html
Normal file
15
templates/_types/slots/_oob_elements.html
Normal 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 %}
|
||||
63
templates/_types/slots/_row.html
Normal file
63
templates/_types/slots/_row.html
Normal 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>
|
||||
19
templates/_types/slots/header/_header.html
Normal file
19
templates/_types/slots/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
19
templates/_types/slots/index.html
Normal file
19
templates/_types/slots/index.html
Normal 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 %}
|
||||
103
templates/_types/ticket_type/_edit.html
Normal file
103
templates/_types/ticket_type/_edit.html
Normal 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>
|
||||
50
templates/_types/ticket_type/_main_panel.html
Normal file
50
templates/_types/ticket_type/_main_panel.html
Normal 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>
|
||||
2
templates/_types/ticket_type/_nav.html
Normal file
2
templates/_types/ticket_type/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
18
templates/_types/ticket_type/_oob_elements.html
Normal file
18
templates/_types/ticket_type/_oob_elements.html
Normal 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 %}
|
||||
33
templates/_types/ticket_type/header/_header.html
Normal file
33
templates/_types/ticket_type/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
19
templates/_types/ticket_type/index.html
Normal file
19
templates/_types/ticket_type/index.html
Normal 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 %}
|
||||
87
templates/_types/ticket_types/_add.html
Normal file
87
templates/_types/ticket_types/_add.html
Normal 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>
|
||||
16
templates/_types/ticket_types/_add_button.html
Normal file
16
templates/_types/ticket_types/_add_button.html
Normal 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>
|
||||
24
templates/_types/ticket_types/_main_panel.html
Normal file
24
templates/_types/ticket_types/_main_panel.html
Normal 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>
|
||||
2
templates/_types/ticket_types/_nav.html
Normal file
2
templates/_types/ticket_types/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
18
templates/_types/ticket_types/_oob_elements.html
Normal file
18
templates/_types/ticket_types/_oob_elements.html
Normal 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 %}
|
||||
57
templates/_types/ticket_types/_row.html
Normal file
57
templates/_types/ticket_types/_row.html
Normal 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>
|
||||
25
templates/_types/ticket_types/header/_header.html
Normal file
25
templates/_types/ticket_types/header/_header.html
Normal 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 %}
|
||||
|
||||
|
||||
|
||||
20
templates/_types/ticket_types/index.html
Normal file
20
templates/_types/ticket_types/index.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user