Compare commits
4 Commits
46f6ca4a0f
...
widget-pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bccfff0c69 | ||
|
|
9a8b556c13 | ||
|
|
a626dd849d | ||
|
|
d0b1edea7a |
80
README.md
80
README.md
@@ -1,6 +1,6 @@
|
||||
# Shared
|
||||
|
||||
Shared infrastructure, models, contracts, services, and templates used by all five Rose Ash microservices (blog, market, cart, events, federation). Included as a git submodule in each app.
|
||||
Shared infrastructure, models, templates, and configuration used by all four Rose Ash microservices (blog, market, cart, events). Included as a git submodule in each app.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -8,78 +8,53 @@ Shared infrastructure, models, contracts, services, and templates used by all fi
|
||||
shared/
|
||||
db/
|
||||
base.py # SQLAlchemy declarative Base
|
||||
session.py # Async session factory (get_session, register_db)
|
||||
models/ # Canonical domain models
|
||||
session.py # Async session factory (get_session)
|
||||
models/ # Shared domain models
|
||||
user.py # User
|
||||
magic_link.py # MagicLink (auth tokens)
|
||||
(domain_event.py removed — table dropped, see migration n4l2i8j0k1)
|
||||
domain_event.py # DomainEvent (transactional outbox)
|
||||
kv.py # KeyValue (key-value store)
|
||||
menu_item.py # MenuItem (deprecated — use MenuNode)
|
||||
menu_node.py # MenuNode (navigation tree)
|
||||
container_relation.py # ContainerRelation (parent-child content)
|
||||
menu_item.py # MenuItem
|
||||
ghost_membership_entities.py # GhostNewsletter, UserNewsletter
|
||||
federation.py # ActorProfile, APActivity, APFollower, APFollowing,
|
||||
# RemoteActor, APRemotePost, APLocalPost,
|
||||
# APInteraction, APNotification, APAnchor, IPFSPin
|
||||
contracts/
|
||||
dtos.py # Frozen dataclasses for cross-domain data transfer
|
||||
protocols.py # Service protocols (Blog, Calendar, Market, Cart, Federation)
|
||||
widgets.py # Widget types (NavWidget, CardWidget, AccountPageWidget)
|
||||
services/
|
||||
registry.py # Typed singleton: services.blog, .calendar, .market, .cart, .federation
|
||||
blog_impl.py # SqlBlogService
|
||||
calendar_impl.py # SqlCalendarService
|
||||
market_impl.py # SqlMarketService
|
||||
cart_impl.py # SqlCartService
|
||||
federation_impl.py # SqlFederationService
|
||||
federation_publish.py # try_publish() — inline AP publication helper
|
||||
stubs.py # No-op stubs for absent domains
|
||||
navigation.py # get_navigation_tree()
|
||||
relationships.py # attach_child, get_children, detach_child
|
||||
widget_registry.py # Widget registry singleton
|
||||
widgets/ # Per-domain widget registration
|
||||
infrastructure/
|
||||
factory.py # create_base_app() — Quart app factory
|
||||
cart_identity.py # current_cart_identity() (user_id or session_id)
|
||||
cart_loader.py # Cart data loader for context processors
|
||||
context.py # Jinja2 context processors
|
||||
internal_api.py # Inter-app HTTP client (get/post via httpx)
|
||||
jinja_setup.py # Jinja2 template environment setup
|
||||
urls.py # URL helpers (coop_url, market_url, etc.)
|
||||
user_loader.py # Load current user from session
|
||||
http_utils.py # HTTP utility functions
|
||||
events/
|
||||
bus.py # emit_activity(), register_activity_handler()
|
||||
processor.py # EventProcessor (polls ap_activities, runs handlers)
|
||||
handlers/ # Shared activity handlers
|
||||
container_handlers.py # Navigation rebuild on attach/detach
|
||||
login_handlers.py # Cart/entry adoption on login
|
||||
order_handlers.py # Order lifecycle events
|
||||
ap_delivery_handler.py # AP activity delivery to follower inboxes (wildcard)
|
||||
utils/
|
||||
__init__.py
|
||||
calendar_helpers.py # Calendar period/entry utilities
|
||||
http_signatures.py # RSA keypair generation, HTTP signature signing/verification
|
||||
ipfs_client.py # Async IPFS client (add_bytes, add_json, pin_cid)
|
||||
anchoring.py # Merkle trees + OpenTimestamps Bitcoin anchoring
|
||||
webfinger.py # WebFinger actor resolution
|
||||
browser/
|
||||
app/ # Middleware, CSRF, errors, Redis caching, authz, filters
|
||||
templates/ # ~300 Jinja2 templates shared across all apps
|
||||
containers.py # ContainerType, container_filter, content_filter helpers
|
||||
bus.py # emit_event(), register_handler()
|
||||
processor.py # EventProcessor (polls domain_events, runs handlers)
|
||||
browser/app/
|
||||
csrf.py # CSRF protection
|
||||
errors.py # Error handlers
|
||||
middleware.py # Request/response middleware
|
||||
redis_cacher.py # Tag-based Redis page caching
|
||||
authz.py # Authorization helpers
|
||||
filters/ # Jinja2 template filters (currency, truncate, etc.)
|
||||
utils/ # HTMX helpers, UTC time, parsing
|
||||
payments/sumup.py # SumUp checkout API integration
|
||||
browser/templates/ # ~300 Jinja2 templates shared across all apps
|
||||
config.py # YAML config loader
|
||||
containers.py # ContainerType, container_filter, content_filter helpers
|
||||
log_config/setup.py # Logging configuration (JSON formatter)
|
||||
utils.py # host_url and other shared utilities
|
||||
static/ # Shared static assets (CSS, JS, images, FontAwesome)
|
||||
editor/ # Koenig (Ghost) rich text editor build
|
||||
alembic/ # Database migrations
|
||||
alembic/ # Database migrations (25 versions)
|
||||
env.py # Imports models from all apps (with try/except guards)
|
||||
versions/ # Migration files — single head: j0h8e4f6g7
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, widget registration, and domain service wiring.
|
||||
- **Service contracts:** Cross-domain communication via typed Protocols + frozen DTO dataclasses. Apps call `services.calendar.method()`, never import models from other domains.
|
||||
- **Service registry:** Typed singleton (`services.blog`, `.calendar`, `.market`, `.cart`, `.federation`). Apps wire their own domain + stubs for others via `register_domain_services()`.
|
||||
- **Activity bus:** `emit_activity()` writes to `ap_activities` table in the caller's transaction. `EventProcessor` polls pending activities and dispatches to registered handlers. Internal events use `visibility="internal"`; federation activities use `visibility="public"` and are delivered to follower inboxes by the wildcard delivery handler.
|
||||
- **Widget registry:** Domain services register widgets (nav, card, account); templates consume via `widgets.container_nav`, `widgets.container_cards`.
|
||||
- **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, and the glue handler registry.
|
||||
- **Event bus:** `emit_event()` writes to `domain_events` table in the caller's transaction. `EventProcessor` polls and dispatches to registered handlers.
|
||||
- **Inter-app HTTP:** `internal_api.get/post("cart", "/internal/cart/summary")` for cross-app reads. URLs resolved from `app-config.yaml`.
|
||||
- **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session.
|
||||
|
||||
## Alembic Migrations
|
||||
@@ -87,5 +62,8 @@ shared/
|
||||
All apps share one PostgreSQL database. Migrations are managed here and run from the blog app's entrypoint (other apps skip migrations on startup).
|
||||
|
||||
```bash
|
||||
# From any app directory (shared/ must be on sys.path)
|
||||
alembic -c shared/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
Current head: `j0h8e4f6g7` (drop cross-domain FK constraints).
|
||||
|
||||
@@ -1 +1 @@
|
||||
# shared package — infrastructure, models, contracts, and services
|
||||
# shared package — extracted from blog/shared_lib/
|
||||
|
||||
@@ -19,7 +19,7 @@ from shared.db.base import Base
|
||||
|
||||
# Import ALL models so Base.metadata sees every table
|
||||
import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost*
|
||||
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models"):
|
||||
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "glue.models"):
|
||||
try:
|
||||
__import__(_mod)
|
||||
except ImportError:
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""add unified event bus columns to ap_activities
|
||||
|
||||
Revision ID: m3k1h7i9j0
|
||||
Revises: l2j0g6h8i9
|
||||
Create Date: 2026-02-22
|
||||
|
||||
Adds processing and visibility columns so ap_activities can serve as the
|
||||
unified event bus for both internal domain events and federation delivery.
|
||||
"""
|
||||
|
||||
revision = "m3k1h7i9j0"
|
||||
down_revision = "l2j0g6h8i9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns with defaults so existing rows stay valid
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column("actor_uri", sa.String(512), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column(
|
||||
"visibility", sa.String(20),
|
||||
nullable=False, server_default="public",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column(
|
||||
"process_state", sa.String(20),
|
||||
nullable=False, server_default="completed",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column(
|
||||
"process_attempts", sa.Integer(),
|
||||
nullable=False, server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column(
|
||||
"process_max_attempts", sa.Integer(),
|
||||
nullable=False, server_default="5",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column("process_error", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ap_activities",
|
||||
sa.Column(
|
||||
"processed_at", sa.DateTime(timezone=True), nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Backfill actor_uri from the related actor_profile
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE ap_activities a
|
||||
SET actor_uri = CONCAT(
|
||||
'https://',
|
||||
COALESCE(current_setting('app.ap_domain', true), 'rose-ash.com'),
|
||||
'/users/',
|
||||
p.preferred_username
|
||||
)
|
||||
FROM ap_actor_profiles p
|
||||
WHERE a.actor_profile_id = p.id
|
||||
AND a.actor_uri IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Make actor_profile_id nullable (internal events have no actor profile)
|
||||
op.alter_column(
|
||||
"ap_activities", "actor_profile_id",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Index for processor polling
|
||||
op.create_index(
|
||||
"ix_ap_activity_process", "ap_activities", ["process_state"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ap_activity_process", table_name="ap_activities")
|
||||
|
||||
# Restore actor_profile_id NOT NULL (remove any rows without it first)
|
||||
op.execute(
|
||||
"DELETE FROM ap_activities WHERE actor_profile_id IS NULL"
|
||||
)
|
||||
op.alter_column(
|
||||
"ap_activities", "actor_profile_id",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
op.drop_column("ap_activities", "processed_at")
|
||||
op.drop_column("ap_activities", "process_error")
|
||||
op.drop_column("ap_activities", "process_max_attempts")
|
||||
op.drop_column("ap_activities", "process_attempts")
|
||||
op.drop_column("ap_activities", "process_state")
|
||||
op.drop_column("ap_activities", "visibility")
|
||||
op.drop_column("ap_activities", "actor_uri")
|
||||
@@ -1,46 +0,0 @@
|
||||
"""drop domain_events table
|
||||
|
||||
Revision ID: n4l2i8j0k1
|
||||
Revises: m3k1h7i9j0
|
||||
Create Date: 2026-02-22
|
||||
|
||||
The domain_events table is no longer used — all events now flow through
|
||||
ap_activities with the unified activity bus.
|
||||
"""
|
||||
|
||||
revision = "n4l2i8j0k1"
|
||||
down_revision = "m3k1h7i9j0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index("ix_domain_events_state", table_name="domain_events")
|
||||
op.drop_index("ix_domain_events_event_type", table_name="domain_events")
|
||||
op.drop_table("domain_events")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"domain_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("event_type", sa.String(128), nullable=False),
|
||||
sa.Column("aggregate_type", sa.String(64), nullable=False),
|
||||
sa.Column("aggregate_id", sa.Integer(), nullable=False),
|
||||
sa.Column("payload", JSONB(), nullable=True),
|
||||
sa.Column("state", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5"),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
nullable=False, server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_domain_events_event_type", "domain_events", ["event_type"])
|
||||
op.create_index("ix_domain_events_state", "domain_events", ["state"])
|
||||
51
browser/templates/_types/blog/_action_buttons.html
Normal file
51
browser/templates/_types/blog/_action_buttons.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
|
||||
<div class="flex flex-wrap gap-2 px-4 py-3">
|
||||
{% if has_access('blog.new_post') %}
|
||||
{% set new_href = url_for('blog.new_post')|host %}
|
||||
<a
|
||||
href="{{ new_href }}"
|
||||
hx-get="{{ new_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
title="New Post"
|
||||
>
|
||||
<i class="fa fa-plus mr-1"></i> New Post
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if g.user and (draft_count or drafts) %}
|
||||
{% if drafts %}
|
||||
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
|
||||
<a
|
||||
href="{{ drafts_off_href }}"
|
||||
hx-get="{{ drafts_off_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
title="Hide Drafts"
|
||||
>
|
||||
<i class="fa fa-file-text-o mr-1"></i> Drafts
|
||||
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
|
||||
<a
|
||||
href="{{ drafts_on_href }}"
|
||||
hx-get="{{ drafts_on_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||
title="Show Drafts"
|
||||
>
|
||||
<i class="fa fa-file-text-o mr-1"></i> Drafts
|
||||
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
48
browser/templates/_types/blog/_main_panel.html
Normal file
48
browser/templates/_types/blog/_main_panel.html
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
{# View toggle bar - desktop only #}
|
||||
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
||||
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
||||
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
|
||||
<a
|
||||
href="{{ list_href }}"
|
||||
hx-get="{{ list_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||
title="List view"
|
||||
_="on click js localStorage.removeItem('blog_view') end"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="{{ tile_href }}"
|
||||
hx-get="{{ tile_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||
title="Tile view"
|
||||
_="on click js localStorage.setItem('blog_view','tile') end"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Cards container - list or grid based on view #}
|
||||
{% if view == 'tile' %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/blog/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="max-w-full px-3 py-3 space-y-3">
|
||||
{% include "_types/blog/_cards.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
37
browser/templates/_types/browse/_oob_elements.html
Normal file
37
browser/templates/_types/browse/_oob_elements.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'market-header-child', '_types/market/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{# Filter container with child summary - from browse/index.html child_summary block #}
|
||||
{% block filter %}
|
||||
{% include "_types/browse/mobile/_filter/summary.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% include "_types/browse/desktop/menu.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/browse/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
180
browser/templates/_types/calendar/_main_panel.html
Normal file
180
browser/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('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
«
|
||||
</a>
|
||||
|
||||
{# Inner left: -1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
|
||||
<div class="px-3 font-medium">
|
||||
{{ month_name }} {{ year }}
|
||||
</div>
|
||||
|
||||
{# Inner right: +1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
›
|
||||
</a>
|
||||
|
||||
{# Outer right: +1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
»
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{# Calendar grid #}
|
||||
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">
|
||||
{# Weekday header: only show on sm+ (desktop/tablet) #}
|
||||
<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">
|
||||
{% for wd in weekday_names %}
|
||||
<div class="py-1">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# On mobile: 1 column; on sm+: 7 columns #}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">
|
||||
{% for week in weeks %}
|
||||
{% for day in week %}
|
||||
<div
|
||||
class="min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %} {% if day.is_today %} ring-2 ring-blue-500 z-10 relative {% endif %}"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col">
|
||||
<span class="sm:hidden text-[16px] text-stone-500">
|
||||
{{ day.date.strftime('%a') }}
|
||||
</span>
|
||||
|
||||
{# Clickable day number: goes to day detail view #}
|
||||
<a
|
||||
class="{{styles.pill}}"
|
||||
href="{{ url_for('calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-get="{{ url_for('calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
{{ day.date.day }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# Entries for this day: merged, chronological #}
|
||||
<div class="mt-1 space-y-0.5">
|
||||
{# Build a list of entries for this specific day.
|
||||
month_entries is already sorted by start_at in Python. #}
|
||||
{% for e in month_entries %}
|
||||
{% if e.start_at.date() == day.date %}
|
||||
{# Decide colour: highlight "mine" differently if you want #}
|
||||
{% set is_mine = (g.user and e.user_id == g.user.id)
|
||||
or (not g.user and e.session_id == qsession.get('calendar_sid')) %}
|
||||
<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5
|
||||
{% if e.state == 'confirmed' %}
|
||||
{% if is_mine %}
|
||||
bg-emerald-200 text-emerald-900
|
||||
{% else %}
|
||||
bg-emerald-100 text-emerald-800
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if is_mine %}
|
||||
bg-sky-100 text-sky-800
|
||||
{% else %}
|
||||
bg-stone-100 text-stone-700
|
||||
{% endif %}
|
||||
{% endif %}">
|
||||
<span class="truncate">
|
||||
{{ e.name }}
|
||||
</span>
|
||||
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">
|
||||
{{ (e.state or 'pending')|replace('_', ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
33
browser/templates/_types/calendar/admin/_description.html
Normal file
33
browser/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(
|
||||
'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 %}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<div id="calendar-description">
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_save',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<textarea
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
rows="4"
|
||||
class="w-full p-2 border rounded"
|
||||
>{{ calendar.description or '' }}</textarea>
|
||||
|
||||
<div class="mt-2 flex gap-2 text-xs">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-1 rounded bg-stone-800 text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded border"
|
||||
hx-get="{{ url_for(
|
||||
'calendars.calendar.admin.calendar_description_view',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
46
browser/templates/_types/calendar/admin/_main_panel.html
Normal file
46
browser/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('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>
|
||||
14
browser/templates/_types/calendar/admin/header/_header.html
Normal file
14
browser/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('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 %}
|
||||
23
browser/templates/_types/calendar/header/_header.html
Normal file
23
browser/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) %}
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') }}" class="flex items-center gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<div class="shrink-0">
|
||||
{{ calendar.name }}
|
||||
</div>
|
||||
</div>
|
||||
{% from '_types/calendar/_description.html' import description %}
|
||||
{{description(calendar)}}
|
||||
</div>
|
||||
</a>
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
20
browser/templates/_types/calendar/index.html
Normal file
20
browser/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 %}
|
||||
169
browser/templates/_types/cart/_cart.html
Normal file
169
browser/templates/_types/cart/_cart.html
Normal file
@@ -0,0 +1,169 @@
|
||||
{% macro show_cart(oob=False) %}
|
||||
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
|
||||
{# Empty cart #}
|
||||
{% if not cart and not calendar_cart_entries %}
|
||||
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
|
||||
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
|
||||
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
|
||||
</div>
|
||||
<p class="text-base sm:text-lg font-medium text-stone-800">
|
||||
Your cart is empty
|
||||
</p>
|
||||
{#
|
||||
<p class="mt-1 text-xs sm:text-sm text-stone-600">
|
||||
Add some items from the shop to see them here.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="{{ market_url('/') }}"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-semibold rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
Browse products
|
||||
</a>
|
||||
</div> #}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div _class="grid gap-y-6 lg:gap-8 lg:grid-cols-[minmax(0,2fr),minmax(0,1fr)]">
|
||||
{# Items list #}
|
||||
<section class="space-y-3 sm:space-y-4">
|
||||
{% for item in cart %}
|
||||
{% from '_types/product/_cart.html' import cart_item with context %}
|
||||
{{ cart_item()}}
|
||||
{% endfor %}
|
||||
{% if calendar_cart_entries %}
|
||||
<div class="mt-6 border-t border-stone-200 pt-4">
|
||||
<h2 class="text-base font-semibold mb-2">
|
||||
Calendar bookings
|
||||
</h2>
|
||||
|
||||
<ul class="space-y-2">
|
||||
{% for entry in calendar_cart_entries %}
|
||||
<li class="flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ entry.name or entry.calendar_name }}
|
||||
</div>
|
||||
<div class="text-xs text-stone-500">
|
||||
{{ entry.start_at }}
|
||||
{% if entry.end_at %}
|
||||
– {{ entry.end_at }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 font-medium">
|
||||
£{{ "%.2f"|format(entry.cost or 0) }}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{{summary(cart, total, calendar_total, calendar_cart_entries,)}}
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro summary(cart, total, calendar_total, calendar_cart_entries, oob=False) %}
|
||||
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
|
||||
<div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">
|
||||
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
|
||||
Order summary
|
||||
</h2>
|
||||
|
||||
<dl class="space-y-2 text-xs sm:text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-stone-600">Items</dt>
|
||||
<dd class="text-stone-900">
|
||||
{{ cart | sum(attribute="quantity") }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-stone-600">Subtotal</dt>
|
||||
<dd class="text-stone-900">
|
||||
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries ) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<h1 class="text-5xl mt-2">
|
||||
This is a test - it will not take actual money
|
||||
</h1>
|
||||
<div>
|
||||
use dummy card number: 5555 5555 5555 4444
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-5">
|
||||
{% if g.user %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ page_cart_url(page_post.slug, '/checkout/') if page_post is defined and page_post else cart_url('/checkout/') }}"
|
||||
class="w-full"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
>
|
||||
<i class="fa-solid fa-credit-card mr-2" aria-hidden="true"></i>
|
||||
Checkout as {{g.user.email}}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% set href=login_url(request.url) %}
|
||||
<div
|
||||
class="w-full flex"
|
||||
>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if local_href == request.path else 'false' }}"
|
||||
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register to checkout</span>
|
||||
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro cart_total(cart, total) %}
|
||||
{% set cart_total = total(cart) %}
|
||||
{% if cart_total %}
|
||||
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
|
||||
{{ symbol }}{{ "%.2f"|format(cart_total) }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries) %}
|
||||
{% set product_total = total(cart) or 0 %}
|
||||
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
|
||||
{% set grand = product_total + cal_total %}
|
||||
|
||||
{% if cart and cart[0].product.regular_price_currency %}
|
||||
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
|
||||
{% else %}
|
||||
{% set symbol = "£" %}
|
||||
{% endif %}
|
||||
|
||||
{{ symbol }}{{ "%.2f"|format(grand) }}
|
||||
{% endmacro %}
|
||||
301
browser/templates/_types/day/_add.html
Normal file
301
browser/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(
|
||||
'calendars.calendar.day.calendar_entries.add_entry',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#day-entries"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# 1) Entry name #}
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
class="border rounded px-3 py-2"
|
||||
placeholder="Entry name"
|
||||
/>
|
||||
|
||||
{# 2) Slot picker for this weekday (required) #}
|
||||
{% if day_slots %}
|
||||
<select
|
||||
name="slot_id"
|
||||
class="border rounded px-3 py-2"
|
||||
data-slot-picker
|
||||
required
|
||||
>
|
||||
{% for slot in day_slots %}
|
||||
<option
|
||||
value="{{ slot.id }}"
|
||||
data-start="{{ slot.time_start.strftime('%H:%M') }}"
|
||||
data-end="{{ slot.time_end.strftime('%H:%M') if slot.time_end else '' }}"
|
||||
data-flexible="{{ '1' if slot | getattr('flexible', False) else '0' }}"
|
||||
data-cost="{{ slot.cost if slot.cost is not none else '0' }}"
|
||||
>
|
||||
{{ slot.name }}
|
||||
({{ slot.time_start.strftime('%H:%M') }}
|
||||
{% if slot.time_end %}–{{ slot.time_end.strftime('%H:%M') }}{% else %}–open-ended{% endif %})
|
||||
{% if slot | getattr('flexible', False) %}[flexible]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="text-sm text-stone-500">
|
||||
No slots defined for this day.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# 3) Time entry + cost display #}
|
||||
<div class="md:col-span-2 flex flex-col gap-2">
|
||||
{# Time inputs — hidden until a flexible slot is selected #}
|
||||
<div data-time-fields class="hidden">
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">From</label>
|
||||
<input
|
||||
name="start_time"
|
||||
type="time"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
data-entry-start
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">To</label>
|
||||
<input
|
||||
name="end_time"
|
||||
type="time"
|
||||
class="border rounded px-3 py-2 w-full"
|
||||
data-entry-end
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-stone-500" data-slot-boundary></p>
|
||||
</div>
|
||||
|
||||
{# Cost display — shown when a slot is selected #}
|
||||
<div data-cost-row class="hidden text-sm font-medium text-stone-700">
|
||||
Estimated Cost: <span data-cost-display class="text-green-600">£0.00</span>
|
||||
</div>
|
||||
|
||||
{# Summary of fixed times — shown for non-flexible slots #}
|
||||
<div data-fixed-summary class="hidden text-sm text-stone-600"></div>
|
||||
</div>
|
||||
|
||||
{# Ticket Configuration #}
|
||||
<div class="md:col-span-4 border-t pt-3 mt-2">
|
||||
<h4 class="text-sm font-semibold text-stone-700 mb-3">Ticket Configuration (Optional)</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">
|
||||
Ticket Price (£)
|
||||
</label>
|
||||
<input
|
||||
name="ticket_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-full border rounded px-3 py-2 text-sm"
|
||||
placeholder="Leave empty for no tickets"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-stone-700 mb-1">
|
||||
Total Tickets
|
||||
</label>
|
||||
<input
|
||||
name="ticket_count"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full border rounded px-3 py-2 text-sm"
|
||||
placeholder="Leave empty for unlimited"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2 md:col-span-4">
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.add_button',
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
year=year,
|
||||
) }}"
|
||||
hx-target="#entry-add-container"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="{{styles.action_button}}"
|
||||
data-confirm="true"
|
||||
data-confirm-title="Add entry?"
|
||||
data-confirm-text="Are you sure you want to add this entry?"
|
||||
data-confirm-icon="question"
|
||||
data-confirm-confirm-text="Yes, add it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
>
|
||||
<i class="fa fa-save"></i>
|
||||
Save entry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# --- Behaviour: lock / unlock times based on slot.flexible --- #}
|
||||
<script>
|
||||
(function () {
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function calculateCost(slotCost, slotStart, slotEnd, actualStart, actualEnd, flexible) {
|
||||
if (!flexible) {
|
||||
// Fixed slot: use full slot cost
|
||||
return parseFloat(slotCost);
|
||||
}
|
||||
|
||||
// Flexible slot: prorate based on time range
|
||||
if (!actualStart || !actualEnd) return 0;
|
||||
|
||||
const slotStartMin = timeToMinutes(slotStart);
|
||||
const slotEndMin = timeToMinutes(slotEnd);
|
||||
const actualStartMin = timeToMinutes(actualStart);
|
||||
const actualEndMin = timeToMinutes(actualEnd);
|
||||
|
||||
const slotDuration = slotEndMin - slotStartMin;
|
||||
const actualDuration = actualEndMin - actualStartMin;
|
||||
|
||||
if (slotDuration <= 0 || actualDuration <= 0) return 0;
|
||||
|
||||
const ratio = actualDuration / slotDuration;
|
||||
return parseFloat(slotCost) * ratio;
|
||||
}
|
||||
|
||||
function initEntrySlotPicker(root, applyInitial = false) {
|
||||
const select = root.querySelector('[data-slot-picker]');
|
||||
if (!select) return;
|
||||
|
||||
const timeFields = root.querySelector('[data-time-fields]');
|
||||
const startInput = root.querySelector('[data-entry-start]');
|
||||
const endInput = root.querySelector('[data-entry-end]');
|
||||
const helper = root.querySelector('[data-slot-boundary]');
|
||||
const costDisplay = root.querySelector('[data-cost-display]');
|
||||
const costRow = root.querySelector('[data-cost-row]');
|
||||
const fixedSummary = root.querySelector('[data-fixed-summary]');
|
||||
|
||||
if (!startInput || !endInput) return;
|
||||
|
||||
function updateCost() {
|
||||
const opt = select.selectedOptions[0];
|
||||
if (!opt || !opt.value) {
|
||||
if (costDisplay) costDisplay.textContent = '£0.00';
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = opt.dataset.cost || '0';
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
const calculatedCost = calculateCost(
|
||||
cost, s, e,
|
||||
startInput.value, endInput.value,
|
||||
flexible
|
||||
);
|
||||
|
||||
if (costDisplay) {
|
||||
costDisplay.textContent = '£' + calculatedCost.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFromOption(opt) {
|
||||
if (!opt || !opt.value) {
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (costRow) costRow.classList.add('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const s = opt.dataset.start || '';
|
||||
const e = opt.dataset.end || '';
|
||||
const flexible = opt.dataset.flexible === '1';
|
||||
|
||||
if (!flexible) {
|
||||
// Fixed slot: hide time inputs, show summary + cost
|
||||
if (s) startInput.value = s;
|
||||
if (e) endInput.value = e;
|
||||
if (timeFields) timeFields.classList.add('hidden');
|
||||
if (fixedSummary) {
|
||||
fixedSummary.classList.remove('hidden');
|
||||
if (e) {
|
||||
fixedSummary.textContent = `${s} – ${e}`;
|
||||
} else {
|
||||
fixedSummary.textContent = `From ${s} (open-ended)`;
|
||||
}
|
||||
}
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
} else {
|
||||
// Flexible slot: show time inputs, hide fixed summary, show cost
|
||||
if (timeFields) timeFields.classList.remove('hidden');
|
||||
if (fixedSummary) fixedSummary.classList.add('hidden');
|
||||
if (costRow) costRow.classList.remove('hidden');
|
||||
if (helper) {
|
||||
if (e) {
|
||||
helper.textContent = `Times must be between ${s} and ${e}.`;
|
||||
} else {
|
||||
helper.textContent = `Start at or after ${s}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCost();
|
||||
}
|
||||
|
||||
// Only apply initial state if explicitly requested (on first load)
|
||||
if (applyInitial) {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
}
|
||||
|
||||
// Remove any existing listener to prevent duplicates
|
||||
if (select._slotChangeHandler) {
|
||||
select.removeEventListener('change', select._slotChangeHandler);
|
||||
}
|
||||
|
||||
select._slotChangeHandler = () => {
|
||||
applyFromOption(select.selectedOptions[0]);
|
||||
};
|
||||
|
||||
select.addEventListener('change', select._slotChangeHandler);
|
||||
|
||||
// Update cost when times change (for flexible slots)
|
||||
startInput.addEventListener('input', updateCost);
|
||||
endInput.addEventListener('input', updateCost);
|
||||
}
|
||||
|
||||
// Initial load - apply initial state
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEntrySlotPicker(document, true);
|
||||
});
|
||||
|
||||
// HTMX fragments - apply initial state so visibility is correct
|
||||
if (window.htmx) {
|
||||
htmx.onLoad((content) => {
|
||||
initEntrySlotPicker(content, true);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
17
browser/templates/_types/day/_add_button.html
Normal file
17
browser/templates/_types/day/_add_button.html
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'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>
|
||||
50
browser/templates/_types/day/_nav.html
Normal file
50
browser/templates/_types/day/_nav.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% 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('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>
|
||||
|
||||
{# Container nav widgets (market links, etc.) #}
|
||||
{% if container_nav_widgets %}
|
||||
{% for wdata in container_nav_widgets %}
|
||||
{% with ctx=wdata.ctx %}
|
||||
{% include wdata.widget.template with context %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Admin link #}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for(
|
||||
'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 %}
|
||||
76
browser/templates/_types/day/_row.html
Normal file
76
browser/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(
|
||||
'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(
|
||||
'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>
|
||||
34
browser/templates/_types/day/admin/_nav_entries_oob.html
Normal file
34
browser/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('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 %}
|
||||
21
browser/templates/_types/day/admin/header/_header.html
Normal file
21
browser/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(
|
||||
'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 %}
|
||||
27
browser/templates/_types/day/header/_header.html
Normal file
27
browser/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(
|
||||
'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
browser/templates/_types/day/index.html
Normal file
18
browser/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
browser/templates/_types/entry/_edit.html
Normal file
334
browser/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(
|
||||
'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(
|
||||
'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
browser/templates/_types/entry/_main_panel.html
Normal file
126
browser/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(
|
||||
'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>
|
||||
48
browser/templates/_types/entry/_nav.html
Normal file
48
browser/templates/_types/entry/_nav.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% 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="{{ coop_url('/' + 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>
|
||||
|
||||
{% if container_nav_widgets %}
|
||||
{% for wdata in container_nav_widgets %}
|
||||
{% with ctx=wdata.ctx %}
|
||||
{% include wdata.widget.template with context %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Admin link #}
|
||||
{% if g.rights.admin %}
|
||||
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for(
|
||||
'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 %}
|
||||
98
browser/templates/_types/entry/_options.html
Normal file
98
browser/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(
|
||||
'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(
|
||||
'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(
|
||||
'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>
|
||||
74
browser/templates/_types/entry/_posts.html
Normal file
74
browser/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(
|
||||
'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(
|
||||
'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>
|
||||
105
browser/templates/_types/entry/_tickets.html
Normal file
105
browser/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(
|
||||
'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>
|
||||
18
browser/templates/_types/entry/admin/_nav.html
Normal file
18
browser/templates/_types/entry/admin/_nav.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'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 %}
|
||||
22
browser/templates/_types/entry/admin/header/_header.html
Normal file
22
browser/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(
|
||||
'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 %}
|
||||
28
browser/templates/_types/entry/header/_header.html
Normal file
28
browser/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(
|
||||
'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
browser/templates/_types/entry/index.html
Normal file
20
browser/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 %}
|
||||
30
browser/templates/_types/market/_oob_elements.html
Normal file
30
browser/templates/_types/market/_oob_elements.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'market-header-child', '_types/market/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/market/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
25
browser/templates/_types/market/index.html
Normal file
25
browser/templates/_types/market/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('market-header-child', '_types/market/header/_header.html') %}
|
||||
{% block market_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block aside %}
|
||||
{# No aside on landing page #}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/market/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
128
browser/templates/_types/post/_meta.html
Normal file
128
browser/templates/_types/post/_meta.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{# --- social/meta_post.html --- #}
|
||||
{# Context expected:
|
||||
site, post, request
|
||||
#}
|
||||
{% if post is not defined %}
|
||||
{% include 'social/meta_base.html' %}
|
||||
{% else %}
|
||||
|
||||
{# Visibility → robots #}
|
||||
{% set is_public = (post.visibility == 'public') %}
|
||||
{% set is_published = (post.status == 'published') %}
|
||||
{% set robots_here = 'index,follow' if (is_public and is_published and not post.email_only) else 'noindex,nofollow' %}
|
||||
|
||||
{# Compute canonical early so both this file and base can use it #}
|
||||
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
|
||||
{% set _post_path = request.path if request else ('/posts/' ~ (post.slug or post.uuid)) %}
|
||||
{% set canonical = post.canonical_url or (_site_url ~ _post_path if _site_url else (request.url if request else None)) %}
|
||||
|
||||
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
|
||||
{% set robots_override = robots_here %}
|
||||
{% include 'social/meta_base.html' %}
|
||||
|
||||
{# ---- Titles / descriptions ---- #}
|
||||
{% set og_title = post.og_title or base_title %}
|
||||
{% set tw_title = post.twitter_title or base_title %}
|
||||
|
||||
{# Description best-effort, trimmed #}
|
||||
{% set desc_source = post.meta_description
|
||||
or post.og_description
|
||||
or post.twitter_description
|
||||
or post.custom_excerpt
|
||||
or post.excerpt
|
||||
or (post.plaintext if post.plaintext else (post.html|striptags if post.html else '')) %}
|
||||
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
|
||||
|
||||
{# Image priority #}
|
||||
{% set image_url = post.og_image
|
||||
or post.twitter_image
|
||||
or post.feature_image
|
||||
or (site().default_image if site and site().default_image else None) %}
|
||||
|
||||
{# Dates #}
|
||||
{% set published_iso = post.published_at.isoformat() if post.published_at else None %}
|
||||
{% set updated_iso = post.updated_at.isoformat() if post.updated_at
|
||||
else (post.created_at.isoformat() if post.created_at else None) %}
|
||||
|
||||
{# Authors / tags #}
|
||||
{% set primary_author = post.primary_author %}
|
||||
{% set authors = post.authors or ([primary_author] if primary_author else []) %}
|
||||
{% set tag_names = (post.tags or []) | map(attribute='name') | list %}
|
||||
{% set is_article = not post.is_page %}
|
||||
|
||||
<title>{{ base_title }}</title>
|
||||
<meta name="description" content="{{ description }}">
|
||||
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
|
||||
{# ---- Open Graph ---- #}
|
||||
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
|
||||
<meta property="og:type" content="{{ 'article' if is_article else 'website' }}">
|
||||
<meta property="og:title" content="{{ og_title }}">
|
||||
<meta property="og:description" content="{{ description }}">
|
||||
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
|
||||
{% if is_article and published_iso %}<meta property="article:published_time" content="{{ published_iso }}">{% endif %}
|
||||
{% if is_article and updated_iso %}
|
||||
<meta property="article:modified_time" content="{{ updated_iso }}">
|
||||
<meta property="og:updatd_time" content="{{ updated_iso }}">
|
||||
{% endif %}
|
||||
{% if is_article and post.primary_tag and post.primary_tag.name %}
|
||||
<meta property="article:section" content="{{ post.primary_tag.name }}">
|
||||
{% endif %}
|
||||
{% if is_article %}
|
||||
{% for t in tag_names %}
|
||||
<meta property="article:tag" content="{{ t }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- Twitter ---- #}
|
||||
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
|
||||
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
|
||||
{% if primary_author and primary_author.twitter %}
|
||||
<meta name="twitter:creator" content="@{{ primary_author.twitter | replace('@','') }}">
|
||||
{% endif %}
|
||||
<meta name="twitter:title" content="{{ tw_title }}">
|
||||
<meta name="twitter:description" content="{{ description }}">
|
||||
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
|
||||
|
||||
{# ---- JSON-LD author value (no list comprehensions) ---- #}
|
||||
{% if authors and authors|length == 1 %}
|
||||
{% set author_value = {"@type": "Person", "name": authors[0].name} %}
|
||||
{% elif authors %}
|
||||
{% set ns = namespace(arr=[]) %}
|
||||
{% for a in authors %}
|
||||
{% set _ = ns.arr.append({"@type": "Person", "name": a.name}) %}
|
||||
{% endfor %}
|
||||
{% set author_value = ns.arr %}
|
||||
{% else %}
|
||||
{% set author_value = none %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- JSON-LD using combine for optionals ---- #}
|
||||
{% set jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting" if is_article else "WebPage",
|
||||
"mainEntityOfPage": canonical,
|
||||
"headline": base_title,
|
||||
"description": description,
|
||||
"image": image_url,
|
||||
"datePublished": published_iso,
|
||||
"author": author_value,
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": site().title if site and site().title else "",
|
||||
"logo": {"@type": "ImageObject", "url": site().logo if site and site().logo else image_url}
|
||||
}
|
||||
} %}
|
||||
|
||||
{% if updated_iso %}
|
||||
{% set jsonld = jsonld | combine({"dateModified": updated_iso}) %}
|
||||
{% endif %}
|
||||
{% if tag_names %}
|
||||
{% set jsonld = jsonld | combine({"keywords": tag_names | join(", ")}) %}
|
||||
{% endif %}
|
||||
|
||||
<script type="application/ld+json">
|
||||
{{ jsonld | tojson }}
|
||||
</script>
|
||||
{% endif %}
|
||||
8
browser/templates/_types/post/_nav.html
Normal file
8
browser/templates/_types/post/_nav.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{# Widget-driven container nav — entries, calendars, markets #}
|
||||
{% if container_nav_widgets %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="entries-calendars-nav-wrapper">
|
||||
{% include '_types/post/admin/_nav_entries.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
18
browser/templates/_types/post/admin/_nav.html
Normal file
18
browser/templates/_types/post/admin/_nav.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="{{styles.nav_button}}">
|
||||
calendars
|
||||
</a>
|
||||
</div>
|
||||
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
entries
|
||||
{% endcall %}
|
||||
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
data
|
||||
{% endcall %}
|
||||
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
edit
|
||||
{% endcall %}
|
||||
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
settings
|
||||
{% endcall %}
|
||||
22
browser/templates/_types/post/admin/_oob_elements.html
Normal file
22
browser/templates/_types/post/admin/_oob_elements.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "oob_elements.html" %}
|
||||
{# OOB elements for post admin page #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('post-header-child', 'post-admin-header-child', '_types/post/admin/header/_header.html')}}
|
||||
|
||||
{% from '_types/post/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/post/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
nowt
|
||||
{% endblock %}
|
||||
18
browser/templates/_types/post/admin/index.html
Normal file
18
browser/templates/_types/post/admin/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends '_types/post/index.html' %}
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
|
||||
{% block post_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
|
||||
{% block post_admin_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/post/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
nowt
|
||||
{% endblock %}
|
||||
19
browser/templates/_types/post/header/_header.html
Normal file
19
browser/templates/_types/post/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='post-row', oob=oob) %}
|
||||
<a href="{{ coop_url('/' + post.slug + '/') }}" class="flex items-center gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||
{% if post.feature_image %}
|
||||
<img
|
||||
src="{{ post.feature_image }}"
|
||||
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
{% endif %}
|
||||
<span>
|
||||
{{ post.title | truncate(160, True, '…') }}
|
||||
</span>
|
||||
</a>
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/post/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
48
browser/templates/_types/post_entries/_main_panel.html
Normal file
48
browser/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>
|
||||
17
browser/templates/_types/post_entries/header/_header.html
Normal file
17
browser/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 %}
|
||||
|
||||
|
||||
|
||||
278
browser/templates/_types/product/_cart.html
Normal file
278
browser/templates/_types/product/_cart.html
Normal file
@@ -0,0 +1,278 @@
|
||||
{% macro add(slug, cart, oob='false') %}
|
||||
{% set quantity = cart
|
||||
| selectattr('product.slug', 'equalto', slug)
|
||||
| sum(attribute='quantity') %}
|
||||
|
||||
<div id="cart-{{ slug }}" {% if oob=='true' %} hx-swap-oob="{{oob}}" {% endif %}>
|
||||
|
||||
{% if not quantity %}
|
||||
<form
|
||||
action="{{ market_product_url(slug, 'cart') }}"
|
||||
method="post"
|
||||
hx-post="{{ market_product_url(slug, 'cart') }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
class="rounded flex items-center"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input
|
||||
type="hidden"
|
||||
name="count"
|
||||
value="1"
|
||||
>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"
|
||||
>
|
||||
<span class="relative inline-flex items-center justify-center">
|
||||
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
|
||||
|
||||
<!-- black + overlaid in the center -->
|
||||
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<div class="rounded flex items-center gap-2">
|
||||
<!-- minus -->
|
||||
<form
|
||||
action="{{ market_product_url(slug, 'cart') }}"
|
||||
method="post"
|
||||
hx-post="{{ market_product_url(slug, 'cart') }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input
|
||||
type="hidden"
|
||||
name="count"
|
||||
value="{{ quantity - 1 }}"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- basket with quantity badge -->
|
||||
<a
|
||||
class="relative inline-flex items-center justify-center text-emerald-700"
|
||||
href="{{ cart_url('/') }}"
|
||||
>
|
||||
<span class="relative inline-flex items-center justify-center">
|
||||
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||
|
||||
<span
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold"
|
||||
>
|
||||
{{ quantity }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- plus -->
|
||||
<form
|
||||
action="{{ market_product_url(slug, 'cart') }}"
|
||||
method="post"
|
||||
hx-post="{{ market_product_url(slug, 'cart') }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input
|
||||
type="hidden"
|
||||
name="count"
|
||||
value="{{ quantity + 1 }}"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
{% macro cart_item(oob=False) %}
|
||||
|
||||
{% set p = item.product %}
|
||||
{% set unit_price = p.special_price or p.regular_price %}
|
||||
<article
|
||||
id="cart-item-{{p.slug}}"
|
||||
{% if oob %}
|
||||
hx-swap-oob="{{oob}}"
|
||||
{% endif %}
|
||||
class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
|
||||
>
|
||||
<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">
|
||||
{% if p.image %}
|
||||
<img
|
||||
src="{{ p.image }}"
|
||||
alt="{{ p.title }}"
|
||||
class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100"
|
||||
loading="lazy"
|
||||
>
|
||||
{% else %}
|
||||
<div
|
||||
class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"
|
||||
>
|
||||
No image
|
||||
</div>'market', 'product', p.slug
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Details #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
|
||||
{% set href=market_product_url(p.slug, market_place=item.market_place) %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx_get="{{href}}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class="hover:text-emerald-700"
|
||||
>
|
||||
{{ p.title }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{% if p.brand %}
|
||||
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||
{{ p.brand }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if item.is_deleted %}
|
||||
<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||
<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>
|
||||
This item is no longer available or price has changed
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Unit price #}
|
||||
<div class="text-left sm:text-right">
|
||||
{% if unit_price %}
|
||||
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
|
||||
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||
{{ symbol }}{{ "%.2f"|format(unit_price) }}
|
||||
</p>
|
||||
{% if p.special_price and p.special_price != p.regular_price %}
|
||||
<p class="text-xs text-stone-400 line-through">
|
||||
{{ symbol }}{{ "%.2f"|format(p.regular_price) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-xs text-stone-500">No price</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||
{% set qty_url = cart_quantity_url(item.product_id) if cart_quantity_url is defined else market_product_url(p.slug, 'cart', item.market_place) %}
|
||||
<form
|
||||
action="{{ qty_url }}"
|
||||
method="post"
|
||||
hx-post="{{ qty_url }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input
|
||||
type="hidden"
|
||||
name="count"
|
||||
value="{{ [item.quantity - 1, 0] | max }}"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</form>
|
||||
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium {{ 'text-stone-400' if item.quantity == 0 }}">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
<form
|
||||
action="{{ qty_url }}"
|
||||
method="post"
|
||||
hx-post="{{ qty_url }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input
|
||||
type="hidden"
|
||||
name="count"
|
||||
value="{{ item.quantity + 1 }}"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if cart_delete_url is defined %}
|
||||
<form
|
||||
action="{{ cart_delete_url(item.product_id) }}"
|
||||
method="post"
|
||||
hx-post="{{ cart_delete_url(item.product_id) }}"
|
||||
hx-trigger="confirmed"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
type="button"
|
||||
data-confirm
|
||||
data-confirm-title="Remove item?"
|
||||
data-confirm-text="Remove {{ p.title }} from your cart?"
|
||||
data-confirm-icon="warning"
|
||||
data-confirm-confirm-text="Yes, remove"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-red-300 text-red-600 hover:bg-red-50"
|
||||
title="Remove from cart"
|
||||
>
|
||||
<i class="fa-solid fa-trash-can text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between sm:justify-end gap-3">
|
||||
{% if unit_price %}
|
||||
{% set line_total = unit_price * item.quantity %}
|
||||
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
|
||||
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||
Line total:
|
||||
{{ symbol }}{{ "%.2f"|format(line_total) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{% endmacro %}
|
||||
@@ -221,11 +221,6 @@ class FederationService(Protocol):
|
||||
self, session: AsyncSession, username: str,
|
||||
) -> list[APFollowerDTO]: ...
|
||||
|
||||
async def get_followers_paginated(
|
||||
self, session: AsyncSession, username: str,
|
||||
page: int = 1, per_page: int = 20,
|
||||
) -> tuple[list[RemoteActorDTO], int]: ...
|
||||
|
||||
async def add_follower(
|
||||
self, session: AsyncSession, username: str,
|
||||
follower_acct: str, follower_inbox: str, follower_actor_url: str,
|
||||
@@ -288,11 +283,6 @@ class FederationService(Protocol):
|
||||
before: datetime | None = None, limit: int = 20,
|
||||
) -> list[TimelineItemDTO]: ...
|
||||
|
||||
async def get_actor_timeline(
|
||||
self, session: AsyncSession, remote_actor_id: int,
|
||||
before: datetime | None = None, limit: int = 20,
|
||||
) -> list[TimelineItemDTO]: ...
|
||||
|
||||
# -- Local posts ----------------------------------------------------------
|
||||
async def create_local_post(
|
||||
self, session: AsyncSession, actor_profile_id: int,
|
||||
|
||||
@@ -15,7 +15,7 @@ _engine = create_async_engine(
|
||||
future=True,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=0, # 0 = unlimited (NullPool equivalent for asyncpg)
|
||||
pool_size=-1 # ned to look at this!!!
|
||||
)
|
||||
|
||||
_Session = async_sessionmaker(
|
||||
@@ -34,42 +34,43 @@ async def get_session():
|
||||
await sess.close()
|
||||
|
||||
|
||||
|
||||
def register_db(app: Quart):
|
||||
|
||||
@app.before_request
|
||||
async def open_session():
|
||||
g.s = _Session()
|
||||
g.tx = await g.s.begin()
|
||||
g.had_error = False
|
||||
@app.before_request
|
||||
async def open_session():
|
||||
g.s = _Session()
|
||||
g.tx = await g.s.begin()
|
||||
g.had_error = False
|
||||
|
||||
@app.after_request
|
||||
async def maybe_commit(response):
|
||||
# Runs BEFORE bytes are sent.
|
||||
if not g.had_error and 200 <= response.status_code < 400:
|
||||
try:
|
||||
if hasattr(g, "tx"):
|
||||
await g.tx.commit()
|
||||
except Exception as e:
|
||||
print(f'commit failed {e}')
|
||||
if hasattr(g, "tx"):
|
||||
await g.tx.rollback()
|
||||
from quart import make_response
|
||||
return await make_response("Commit failed", 500)
|
||||
return response
|
||||
@app.after_request
|
||||
async def maybe_commit(response):
|
||||
# Runs BEFORE bytes are sent.
|
||||
if not g.had_error and 200 <= response.status_code < 400:
|
||||
try:
|
||||
if hasattr(g, "tx"):
|
||||
await g.tx.commit()
|
||||
except Exception as e:
|
||||
print(f'commit failed {e}')
|
||||
if hasattr(g, "tx"):
|
||||
await g.tx.rollback()
|
||||
from quart import make_response
|
||||
return await make_response("Commit failed", 500)
|
||||
return response
|
||||
|
||||
@app.teardown_request
|
||||
async def finish(exc):
|
||||
try:
|
||||
# If an exception occurred OR we didn't commit (still in txn), roll back.
|
||||
if hasattr(g, "s"):
|
||||
if exc is not None or g.s.in_transaction():
|
||||
if hasattr(g, "tx"):
|
||||
await g.tx.rollback()
|
||||
finally:
|
||||
if hasattr(g, "s"):
|
||||
await g.s.close()
|
||||
@app.teardown_request
|
||||
async def finish(exc):
|
||||
try:
|
||||
# If an exception occurred OR we didn't commit (still in txn), roll back.
|
||||
if hasattr(g, "s"):
|
||||
if exc is not None or g.s.in_transaction():
|
||||
if hasattr(g, "tx"):
|
||||
await g.tx.rollback()
|
||||
finally:
|
||||
if hasattr(g, "s"):
|
||||
await g.s.close()
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
async def mark_error(e):
|
||||
g.had_error = True
|
||||
raise
|
||||
@app.errorhandler(Exception)
|
||||
async def mark_error(e):
|
||||
g.had_error = True
|
||||
raise
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
from .bus import emit_activity, register_activity_handler, get_activity_handlers
|
||||
from .bus import emit_event, register_handler
|
||||
from .processor import EventProcessor
|
||||
|
||||
__all__ = [
|
||||
"emit_activity",
|
||||
"register_activity_handler",
|
||||
"get_activity_handlers",
|
||||
"EventProcessor",
|
||||
]
|
||||
__all__ = ["emit_event", "register_handler", "EventProcessor"]
|
||||
|
||||
127
events/bus.py
127
events/bus.py
@@ -1,109 +1,56 @@
|
||||
"""
|
||||
Unified activity bus.
|
||||
Transactional outbox event bus.
|
||||
|
||||
emit_activity() writes an APActivity row with process_state='pending' within
|
||||
the caller's existing DB transaction — atomic with the domain change.
|
||||
emit_event() writes to the domain_events table within the caller's existing
|
||||
DB transaction — atomic with whatever domain change triggered the event.
|
||||
|
||||
register_activity_handler() registers async handler functions that the
|
||||
EventProcessor dispatches when processing pending activities.
|
||||
register_handler() registers async handler functions that the EventProcessor
|
||||
will call when processing events of a given type.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Awaitable, Callable, Dict, List, Tuple
|
||||
from typing import Any, Awaitable, Callable, Dict, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.federation import APActivity
|
||||
from shared.models.domain_event import DomainEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activity-handler registry
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler signature: async def handler(activity: APActivity, session: AsyncSession) -> None
|
||||
ActivityHandlerFn = Callable[[APActivity, AsyncSession], Awaitable[None]]
|
||||
# handler signature: async def handler(event: DomainEvent, session: AsyncSession) -> None
|
||||
HandlerFn = Callable[[DomainEvent, AsyncSession], Awaitable[None]]
|
||||
|
||||
# Keyed by (activity_type, object_type). object_type="*" is wildcard.
|
||||
_activity_handlers: Dict[Tuple[str, str], List[ActivityHandlerFn]] = defaultdict(list)
|
||||
_handlers: Dict[str, List[HandlerFn]] = defaultdict(list)
|
||||
|
||||
|
||||
def register_activity_handler(
|
||||
activity_type: str,
|
||||
fn: ActivityHandlerFn,
|
||||
*,
|
||||
object_type: str | None = None,
|
||||
) -> None:
|
||||
"""Register an async handler for an activity type + optional object type.
|
||||
|
||||
Use ``activity_type="*"`` as a wildcard that fires for every activity
|
||||
(e.g. federation delivery handler).
|
||||
"""
|
||||
key = (activity_type, object_type or "*")
|
||||
_activity_handlers[key].append(fn)
|
||||
|
||||
|
||||
def get_activity_handlers(
|
||||
activity_type: str,
|
||||
object_type: str | None = None,
|
||||
) -> List[ActivityHandlerFn]:
|
||||
"""Return all matching handlers for an activity.
|
||||
|
||||
Matches in order:
|
||||
1. Exact (activity_type, object_type)
|
||||
2. (activity_type, "*") — type-level wildcard
|
||||
3. ("*", "*") — global wildcard (e.g. delivery)
|
||||
"""
|
||||
handlers: List[ActivityHandlerFn] = []
|
||||
ot = object_type or "*"
|
||||
|
||||
# Exact match
|
||||
if ot != "*":
|
||||
handlers.extend(_activity_handlers.get((activity_type, ot), []))
|
||||
# Type-level wildcard
|
||||
handlers.extend(_activity_handlers.get((activity_type, "*"), []))
|
||||
# Global wildcard
|
||||
if activity_type != "*":
|
||||
handlers.extend(_activity_handlers.get(("*", "*"), []))
|
||||
|
||||
return handlers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# emit_activity — the primary way to emit events
|
||||
# ---------------------------------------------------------------------------
|
||||
async def emit_activity(
|
||||
async def emit_event(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
activity_type: str,
|
||||
actor_uri: str,
|
||||
object_type: str,
|
||||
object_data: dict | None = None,
|
||||
source_type: str | None = None,
|
||||
source_id: int | None = None,
|
||||
visibility: str = "internal",
|
||||
actor_profile_id: int | None = None,
|
||||
) -> APActivity:
|
||||
event_type: str,
|
||||
aggregate_type: str,
|
||||
aggregate_id: int,
|
||||
payload: Dict[str, Any] | None = None,
|
||||
) -> DomainEvent:
|
||||
"""
|
||||
Write an AP-shaped activity to ap_activities with process_state='pending'.
|
||||
Write a domain event to the outbox table in the current transaction.
|
||||
|
||||
Called inside a service function using the same session that performs the
|
||||
domain change. The activity and the change commit together.
|
||||
Call this inside your service function, using the same session that
|
||||
performs the domain change. The event and the change commit together.
|
||||
"""
|
||||
activity_uri = f"internal:{uuid.uuid4()}" if visibility == "internal" else f"urn:uuid:{uuid.uuid4()}"
|
||||
|
||||
activity = APActivity(
|
||||
activity_id=activity_uri,
|
||||
activity_type=activity_type,
|
||||
actor_profile_id=actor_profile_id,
|
||||
actor_uri=actor_uri,
|
||||
object_type=object_type,
|
||||
object_data=object_data or {},
|
||||
is_local=True,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
visibility=visibility,
|
||||
process_state="pending",
|
||||
event = DomainEvent(
|
||||
event_type=event_type,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=aggregate_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
session.add(activity)
|
||||
await session.flush()
|
||||
return activity
|
||||
session.add(event)
|
||||
await session.flush() # assign event.id
|
||||
return event
|
||||
|
||||
|
||||
def register_handler(event_type: str, fn: HandlerFn) -> None:
|
||||
"""Register an async handler for a given event type."""
|
||||
_handlers[event_type].append(fn)
|
||||
|
||||
|
||||
def get_handlers(event_type: str) -> List[HandlerFn]:
|
||||
"""Return all registered handlers for an event type."""
|
||||
return _handlers.get(event_type, [])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared event handlers."""
|
||||
"""Shared event handlers (replaces glue.setup.register_glue_handlers)."""
|
||||
|
||||
|
||||
def register_shared_handlers():
|
||||
@@ -6,4 +6,5 @@ def register_shared_handlers():
|
||||
import shared.events.handlers.container_handlers # noqa: F401
|
||||
import shared.events.handlers.login_handlers # noqa: F401
|
||||
import shared.events.handlers.order_handlers # noqa: F401
|
||||
# federation_handlers removed — publication is now inline at write sites
|
||||
import shared.events.handlers.ap_delivery_handler # noqa: F401
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Deliver AP activities to remote followers.
|
||||
|
||||
Registered as a wildcard handler — fires for every activity. Skips
|
||||
non-public activities and those without an actor profile.
|
||||
On ``federation.activity_created`` → load activity + actor + followers →
|
||||
sign with HTTP Signatures → POST to each follower inbox.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,7 +11,7 @@ import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events.bus import register_activity_handler
|
||||
from shared.events.bus import register_handler, DomainEvent
|
||||
from shared.models.federation import ActorProfile, APActivity, APFollower
|
||||
from shared.services.registry import services
|
||||
|
||||
@@ -33,9 +33,12 @@ def _build_activity_json(activity: APActivity, actor: ActorProfile, domain: str)
|
||||
object_id = activity.activity_id + "/object"
|
||||
|
||||
if activity.activity_type == "Delete":
|
||||
# Delete: object is a Tombstone with just id + type
|
||||
obj.setdefault("id", object_id)
|
||||
obj.setdefault("type", "Tombstone")
|
||||
else:
|
||||
# Create/Update: full object with attribution
|
||||
# Prefer stable id from object_data (set by try_publish), fall back to activity-derived
|
||||
obj.setdefault("id", object_id)
|
||||
obj.setdefault("type", activity.object_type)
|
||||
obj.setdefault("attributedTo", actor_url)
|
||||
@@ -102,20 +105,30 @@ async def _deliver_to_inbox(
|
||||
return False
|
||||
|
||||
|
||||
async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
"""Deliver a public activity to all followers of its actor."""
|
||||
async def on_activity_created(event: DomainEvent, session: AsyncSession) -> None:
|
||||
"""Deliver a newly created activity to all followers."""
|
||||
import os
|
||||
|
||||
# Only deliver public activities that have an actor profile
|
||||
if activity.visibility != "public":
|
||||
return
|
||||
if activity.actor_profile_id is None:
|
||||
return
|
||||
if not services.has("federation"):
|
||||
return
|
||||
|
||||
payload = event.payload
|
||||
activity_id_uri = payload.get("activity_id")
|
||||
if not activity_id_uri:
|
||||
return
|
||||
|
||||
domain = os.getenv("AP_DOMAIN", "rose-ash.com")
|
||||
|
||||
# Load the activity
|
||||
activity = (
|
||||
await session.execute(
|
||||
select(APActivity).where(APActivity.activity_id == activity_id_uri)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not activity:
|
||||
log.warning("Activity not found: %s", activity_id_uri)
|
||||
return
|
||||
|
||||
# Load actor with private key
|
||||
actor = (
|
||||
await session.execute(
|
||||
@@ -123,7 +136,7 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not actor or not actor.private_key_pem:
|
||||
log.warning("Actor not found or missing key for activity %s", activity.activity_id)
|
||||
log.warning("Actor not found or missing key for activity %s", activity_id_uri)
|
||||
return
|
||||
|
||||
# Load followers
|
||||
@@ -134,13 +147,14 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
).scalars().all()
|
||||
|
||||
if not followers:
|
||||
log.debug("No followers to deliver to for %s", activity.activity_id)
|
||||
log.debug("No followers to deliver to for %s", activity_id_uri)
|
||||
return
|
||||
|
||||
# Build activity JSON
|
||||
activity_json = _build_activity_json(activity, actor, domain)
|
||||
|
||||
# Deduplicate inboxes
|
||||
# Deliver to each follower inbox
|
||||
# Deduplicate inboxes (multiple followers might share a shared inbox)
|
||||
inboxes = {f.follower_inbox for f in followers if f.follower_inbox}
|
||||
|
||||
log.info(
|
||||
@@ -153,5 +167,4 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
||||
await _deliver_to_inbox(client, inbox_url, activity_json, actor, domain)
|
||||
|
||||
|
||||
# Wildcard: fires for every activity
|
||||
register_activity_handler("*", on_any_activity)
|
||||
register_handler("federation.activity_created", on_activity_created)
|
||||
|
||||
@@ -2,18 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events import register_activity_handler
|
||||
from shared.models.federation import APActivity
|
||||
from shared.events import register_handler
|
||||
from shared.models.domain_event import DomainEvent
|
||||
from shared.services.navigation import rebuild_navigation
|
||||
|
||||
|
||||
async def on_child_attached(activity: APActivity, session: AsyncSession) -> None:
|
||||
async def on_child_attached(event: DomainEvent, session: AsyncSession) -> None:
|
||||
await rebuild_navigation(session)
|
||||
|
||||
|
||||
async def on_child_detached(activity: APActivity, session: AsyncSession) -> None:
|
||||
async def on_child_detached(event: DomainEvent, session: AsyncSession) -> None:
|
||||
await rebuild_navigation(session)
|
||||
|
||||
|
||||
register_activity_handler("Add", on_child_attached, object_type="rose:ContainerRelation")
|
||||
register_activity_handler("Remove", on_child_detached, object_type="rose:ContainerRelation")
|
||||
register_handler("container.child_attached", on_child_attached)
|
||||
register_handler("container.child_detached", on_child_detached)
|
||||
|
||||
8
events/handlers/federation_handlers.py
Normal file
8
events/handlers/federation_handlers.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Federation event handlers — REMOVED.
|
||||
|
||||
Federation publication is now inline at the write site (ghost_sync, entries,
|
||||
market routes) via shared.services.federation_publish.try_publish().
|
||||
|
||||
AP delivery (federation.activity_created → inbox POST) remains async via
|
||||
ap_delivery_handler.
|
||||
"""
|
||||
@@ -2,22 +2,24 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events import register_activity_handler
|
||||
from shared.models.federation import APActivity
|
||||
from shared.events import register_handler
|
||||
from shared.models.domain_event import DomainEvent
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
async def on_user_logged_in(activity: APActivity, session: AsyncSession) -> None:
|
||||
data = activity.object_data
|
||||
user_id = data["user_id"]
|
||||
session_id = data["session_id"]
|
||||
async def on_user_logged_in(event: DomainEvent, session: AsyncSession) -> None:
|
||||
payload = event.payload
|
||||
user_id = payload["user_id"]
|
||||
session_id = payload["session_id"]
|
||||
|
||||
# Adopt cart items (if cart service is registered)
|
||||
if services.has("cart"):
|
||||
await services.cart.adopt_cart_for_user(session, user_id, session_id)
|
||||
|
||||
# Adopt calendar entries and tickets (if calendar service is registered)
|
||||
if services.has("calendar"):
|
||||
await services.calendar.adopt_entries_for_user(session, user_id, session_id)
|
||||
await services.calendar.adopt_tickets_for_user(session, user_id, session_id)
|
||||
|
||||
|
||||
register_activity_handler("rose:Login", on_user_logged_in)
|
||||
register_handler("user.logged_in", on_user_logged_in)
|
||||
|
||||
@@ -4,19 +4,19 @@ import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events import register_activity_handler
|
||||
from shared.models.federation import APActivity
|
||||
from shared.events import register_handler
|
||||
from shared.models.domain_event import DomainEvent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def on_order_created(activity: APActivity, session: AsyncSession) -> None:
|
||||
log.info("order.created: order_id=%s", activity.object_data.get("order_id"))
|
||||
async def on_order_created(event: DomainEvent, session: AsyncSession) -> None:
|
||||
log.info("order.created: order_id=%s", event.payload.get("order_id"))
|
||||
|
||||
|
||||
async def on_order_paid(activity: APActivity, session: AsyncSession) -> None:
|
||||
log.info("order.paid: order_id=%s", activity.object_data.get("order_id"))
|
||||
async def on_order_paid(event: DomainEvent, session: AsyncSession) -> None:
|
||||
log.info("order.paid: order_id=%s", event.payload.get("order_id"))
|
||||
|
||||
|
||||
register_activity_handler("Create", on_order_created, object_type="rose:Order")
|
||||
register_activity_handler("rose:OrderPaid", on_order_paid)
|
||||
register_handler("order.created", on_order_created)
|
||||
register_handler("order.paid", on_order_paid)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Event processor — polls the ap_activities table and dispatches to registered
|
||||
activity handlers.
|
||||
Event processor — polls the domain_events outbox table and dispatches
|
||||
to registered handlers.
|
||||
|
||||
Runs as an asyncio background task within each app process.
|
||||
Uses SELECT ... FOR UPDATE SKIP LOCKED for safe concurrent processing.
|
||||
@@ -11,16 +11,16 @@ import asyncio
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.db.session import get_session
|
||||
from shared.models.federation import APActivity
|
||||
from .bus import get_activity_handlers
|
||||
from shared.models.domain_event import DomainEvent
|
||||
from .bus import get_handlers
|
||||
|
||||
|
||||
class EventProcessor:
|
||||
"""Background event processor that polls the ap_activities table."""
|
||||
"""Background event processor that polls the outbox table."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -64,52 +64,54 @@ class EventProcessor:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
async def _process_batch(self) -> int:
|
||||
"""Fetch and process a batch of pending activities. Returns count processed."""
|
||||
"""Fetch and process a batch of pending events. Returns count processed."""
|
||||
processed = 0
|
||||
async with get_session() as session:
|
||||
# FOR UPDATE SKIP LOCKED: safe for concurrent processors
|
||||
stmt = (
|
||||
select(APActivity)
|
||||
select(DomainEvent)
|
||||
.where(
|
||||
APActivity.process_state == "pending",
|
||||
APActivity.process_attempts < APActivity.process_max_attempts,
|
||||
DomainEvent.state == "pending",
|
||||
DomainEvent.attempts < DomainEvent.max_attempts,
|
||||
)
|
||||
.order_by(APActivity.created_at)
|
||||
.order_by(DomainEvent.created_at)
|
||||
.limit(self._batch_size)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
activities = result.scalars().all()
|
||||
events = result.scalars().all()
|
||||
|
||||
for activity in activities:
|
||||
await self._process_one(session, activity)
|
||||
for event in events:
|
||||
await self._process_one(session, event)
|
||||
processed += 1
|
||||
|
||||
await session.commit()
|
||||
return processed
|
||||
|
||||
async def _process_one(self, session: AsyncSession, activity: APActivity) -> None:
|
||||
"""Run all handlers for a single activity."""
|
||||
handlers = get_activity_handlers(activity.activity_type, activity.object_type)
|
||||
async def _process_one(self, session: AsyncSession, event: DomainEvent) -> None:
|
||||
"""Run all handlers for a single event."""
|
||||
handlers = get_handlers(event.event_type)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
activity.process_state = "processing"
|
||||
activity.process_attempts += 1
|
||||
event.state = "processing"
|
||||
event.attempts += 1
|
||||
await session.flush()
|
||||
|
||||
if not handlers:
|
||||
activity.process_state = "completed"
|
||||
activity.processed_at = now
|
||||
# No handlers registered — mark completed (nothing to do)
|
||||
event.state = "completed"
|
||||
event.processed_at = now
|
||||
return
|
||||
|
||||
try:
|
||||
for handler in handlers:
|
||||
await handler(activity, session)
|
||||
activity.process_state = "completed"
|
||||
activity.processed_at = now
|
||||
await handler(event, session)
|
||||
event.state = "completed"
|
||||
event.processed_at = now
|
||||
except Exception as exc:
|
||||
activity.process_error = f"{exc.__class__.__name__}: {exc}"
|
||||
if activity.process_attempts >= activity.process_max_attempts:
|
||||
activity.process_state = "failed"
|
||||
activity.processed_at = now
|
||||
event.last_error = f"{exc.__class__.__name__}: {exc}"
|
||||
if event.attempts >= event.max_attempts:
|
||||
event.state = "failed"
|
||||
event.processed_at = now
|
||||
else:
|
||||
activity.process_state = "pending" # retry
|
||||
event.state = "pending" # retry
|
||||
|
||||
@@ -67,16 +67,8 @@ def market_product_url(product_slug: str, suffix: str = "", market_place=None) -
|
||||
|
||||
def login_url(next_url: str = "") -> str:
|
||||
# Auth lives in blog (coop) for now. Set AUTH_APP=federation to switch.
|
||||
from quart import session as qsession
|
||||
auth_app = os.getenv("AUTH_APP", "coop")
|
||||
base = app_url(auth_app, "/auth/login/")
|
||||
params: list[str] = []
|
||||
if next_url:
|
||||
params.append(f"next={quote(next_url, safe='')}")
|
||||
# Pass anonymous cart session so the auth app can adopt it on login
|
||||
cart_sid = qsession.get("cart_sid")
|
||||
if cart_sid:
|
||||
params.append(f"cart_sid={quote(cart_sid, safe='')}")
|
||||
if params:
|
||||
return f"{base}?{'&'.join(params)}"
|
||||
return f"{base}?next={quote(next_url, safe='')}"
|
||||
return base
|
||||
|
||||
@@ -8,6 +8,8 @@ from .ghost_membership_entities import (
|
||||
GhostNewsletter, UserNewsletter,
|
||||
GhostTier, GhostSubscription,
|
||||
)
|
||||
from .domain_event import DomainEvent
|
||||
|
||||
from .ghost_content import Tag, Post, Author, PostAuthor, PostTag, PostLike
|
||||
from .page_config import PageConfig
|
||||
from .order import Order, OrderItem
|
||||
|
||||
30
models/domain_event.py
Normal file
30
models/domain_event.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from shared.db.base import Base
|
||||
|
||||
|
||||
class DomainEvent(Base):
|
||||
__tablename__ = "domain_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
aggregate_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending", server_default="pending", index=True
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5, server_default="5")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DomainEvent {self.id} {self.event_type} [{self.state}]>"
|
||||
@@ -50,19 +50,14 @@ class ActorProfile(Base):
|
||||
|
||||
|
||||
class APActivity(Base):
|
||||
"""An ActivityPub activity (local or remote).
|
||||
|
||||
Also serves as the unified event bus: internal domain events and public
|
||||
federation activities both live here, distinguished by ``visibility``.
|
||||
The ``EventProcessor`` polls rows with ``process_state='pending'``.
|
||||
"""
|
||||
"""An ActivityPub activity (local or remote)."""
|
||||
__tablename__ = "ap_activities"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||
activity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor_profile_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=True,
|
||||
actor_profile_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||
)
|
||||
object_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
object_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
@@ -88,27 +83,6 @@ class APActivity(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
# --- Unified event-bus columns ---
|
||||
actor_uri: Mapped[str | None] = mapped_column(
|
||||
String(512), nullable=True,
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="public", server_default="public",
|
||||
)
|
||||
process_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="completed", server_default="completed",
|
||||
)
|
||||
process_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0",
|
||||
)
|
||||
process_max_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=5, server_default="5",
|
||||
)
|
||||
process_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
actor_profile = relationship("ActorProfile", back_populates="activities")
|
||||
|
||||
@@ -116,7 +90,6 @@ class APActivity(Base):
|
||||
Index("ix_ap_activity_actor", "actor_profile_id"),
|
||||
Index("ix_ap_activity_source", "source_type", "source_id"),
|
||||
Index("ix_ap_activity_published", "published"),
|
||||
Index("ix_ap_activity_process", "process_state"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -6,7 +6,7 @@ from shared.db.base import Base
|
||||
|
||||
|
||||
class MenuItem(Base):
|
||||
"""Deprecated — kept so the table isn't dropped. Use shared.models.menu_node.MenuNode."""
|
||||
"""Deprecated — kept so the table isn't dropped. Use glue.models.MenuNode."""
|
||||
__tablename__ = "menu_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -371,7 +371,7 @@ class SqlCalendarService:
|
||||
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
|
||||
return entries_by_post
|
||||
|
||||
# -- writes ---------------------------------------------------------------
|
||||
# -- writes (absorb glue lifecycle) ---------------------------------------
|
||||
|
||||
async def adopt_entries_for_user(
|
||||
self, session: AsyncSession, user_id: int, session_id: str,
|
||||
|
||||
@@ -183,21 +183,16 @@ class SqlFederationService:
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
actor_url = f"https://{domain}/users/{username}"
|
||||
|
||||
activity = APActivity(
|
||||
activity_id=activity_uri,
|
||||
activity_type=activity_type,
|
||||
actor_profile_id=actor.id,
|
||||
actor_uri=actor_url,
|
||||
object_type=object_type,
|
||||
object_data=object_data,
|
||||
published=now,
|
||||
is_local=True,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
visibility="public",
|
||||
process_state="pending",
|
||||
)
|
||||
session.add(activity)
|
||||
await session.flush()
|
||||
@@ -213,7 +208,7 @@ class SqlFederationService:
|
||||
],
|
||||
"id": activity_uri,
|
||||
"type": activity_type,
|
||||
"actor": actor_url,
|
||||
"actor": f"https://{domain}/users/{username}",
|
||||
"published": now.isoformat(),
|
||||
"object": {
|
||||
"type": object_type,
|
||||
@@ -226,6 +221,21 @@ class SqlFederationService:
|
||||
except Exception:
|
||||
pass # IPFS failure is non-fatal
|
||||
|
||||
# Emit domain event for downstream processing (delivery)
|
||||
from shared.events import emit_event
|
||||
await emit_event(
|
||||
session,
|
||||
"federation.activity_created",
|
||||
"APActivity",
|
||||
activity.id,
|
||||
{
|
||||
"activity_id": activity.activity_id,
|
||||
"activity_type": activity_type,
|
||||
"actor_username": username,
|
||||
"object_type": object_type,
|
||||
},
|
||||
)
|
||||
|
||||
return _activity_to_dto(activity)
|
||||
|
||||
# -- Queries --------------------------------------------------------------
|
||||
@@ -366,65 +376,6 @@ class SqlFederationService:
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def get_followers_paginated(
|
||||
self, session: AsyncSession, username: str,
|
||||
page: int = 1, per_page: int = 20,
|
||||
) -> tuple[list[RemoteActorDTO], int]:
|
||||
actor = (
|
||||
await session.execute(
|
||||
select(ActorProfile).where(ActorProfile.preferred_username == username)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if actor is None:
|
||||
return [], 0
|
||||
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count(APFollower.id)).where(
|
||||
APFollower.actor_profile_id == actor.id,
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
followers = (
|
||||
await session.execute(
|
||||
select(APFollower)
|
||||
.where(APFollower.actor_profile_id == actor.id)
|
||||
.order_by(APFollower.created_at.desc())
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
results: list[RemoteActorDTO] = []
|
||||
for f in followers:
|
||||
# Try to resolve from cached remote actors first
|
||||
remote = (
|
||||
await session.execute(
|
||||
select(RemoteActor).where(
|
||||
RemoteActor.actor_url == f.follower_actor_url,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if remote:
|
||||
results.append(_remote_actor_to_dto(remote))
|
||||
else:
|
||||
# Synthesise a minimal DTO from follower data
|
||||
from urllib.parse import urlparse
|
||||
domain = urlparse(f.follower_actor_url).netloc
|
||||
results.append(RemoteActorDTO(
|
||||
id=0,
|
||||
actor_url=f.follower_actor_url,
|
||||
inbox_url=f.follower_inbox,
|
||||
preferred_username=f.follower_acct.split("@")[0] if "@" in f.follower_acct else f.follower_acct,
|
||||
domain=domain,
|
||||
display_name=None,
|
||||
summary=None,
|
||||
icon_url=None,
|
||||
))
|
||||
return results, total
|
||||
|
||||
# -- Remote actors --------------------------------------------------------
|
||||
|
||||
async def get_or_fetch_remote_actor(
|
||||
@@ -1015,46 +966,6 @@ class SqlFederationService:
|
||||
))
|
||||
return items
|
||||
|
||||
async def get_actor_timeline(
|
||||
self, session: AsyncSession, remote_actor_id: int,
|
||||
before: datetime | None = None, limit: int = 20,
|
||||
) -> list[TimelineItemDTO]:
|
||||
remote_actor = (
|
||||
await session.execute(
|
||||
select(RemoteActor).where(RemoteActor.id == remote_actor_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not remote_actor:
|
||||
return []
|
||||
|
||||
q = (
|
||||
select(APRemotePost)
|
||||
.where(APRemotePost.remote_actor_id == remote_actor_id)
|
||||
)
|
||||
if before:
|
||||
q = q.where(APRemotePost.published < before)
|
||||
q = q.order_by(APRemotePost.published.desc()).limit(limit)
|
||||
|
||||
posts = (await session.execute(q)).scalars().all()
|
||||
return [
|
||||
TimelineItemDTO(
|
||||
id=f"remote:{p.id}",
|
||||
post_type="remote",
|
||||
content=p.content or "",
|
||||
published=p.published,
|
||||
actor_name=remote_actor.display_name or remote_actor.preferred_username,
|
||||
actor_username=remote_actor.preferred_username,
|
||||
object_id=p.object_id,
|
||||
summary=p.summary,
|
||||
url=p.url,
|
||||
actor_domain=remote_actor.domain,
|
||||
actor_icon=remote_actor.icon_url,
|
||||
actor_url=remote_actor.actor_url,
|
||||
author_inbox=remote_actor.inbox_url,
|
||||
)
|
||||
for p in posts
|
||||
]
|
||||
|
||||
# -- Local posts ----------------------------------------------------------
|
||||
|
||||
async def create_local_post(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Inline federation publication — called at write time, not via async handler.
|
||||
|
||||
The originating service calls try_publish() directly, which creates the
|
||||
APActivity (with process_state='pending') in the same DB transaction.
|
||||
The EventProcessor picks it up and the delivery wildcard handler POSTs
|
||||
to follower inboxes.
|
||||
Replaces the old pattern where emit_event("post.published") → async handler →
|
||||
publish_activity(). Now the originating service calls try_publish() directly,
|
||||
which creates the APActivity in the same DB transaction. AP delivery
|
||||
(federation.activity_created → inbox POST) stays async.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events import emit_activity
|
||||
from shared.events import emit_event
|
||||
from shared.models.container_relation import ContainerRelation
|
||||
|
||||
|
||||
@@ -40,19 +40,17 @@ async def attach_child(
|
||||
if label is not None:
|
||||
existing.label = label
|
||||
await session.flush()
|
||||
await emit_activity(
|
||||
await emit_event(
|
||||
session,
|
||||
activity_type="Add",
|
||||
actor_uri="internal:system",
|
||||
object_type="rose:ContainerRelation",
|
||||
object_data={
|
||||
event_type="container.child_attached",
|
||||
aggregate_type="container_relation",
|
||||
aggregate_id=existing.id,
|
||||
payload={
|
||||
"parent_type": parent_type,
|
||||
"parent_id": parent_id,
|
||||
"child_type": child_type,
|
||||
"child_id": child_id,
|
||||
},
|
||||
source_type="container_relation",
|
||||
source_id=existing.id,
|
||||
)
|
||||
return existing
|
||||
# Already attached and active — no-op
|
||||
@@ -79,19 +77,17 @@ async def attach_child(
|
||||
session.add(rel)
|
||||
await session.flush()
|
||||
|
||||
await emit_activity(
|
||||
await emit_event(
|
||||
session,
|
||||
activity_type="Add",
|
||||
actor_uri="internal:system",
|
||||
object_type="rose:ContainerRelation",
|
||||
object_data={
|
||||
event_type="container.child_attached",
|
||||
aggregate_type="container_relation",
|
||||
aggregate_id=rel.id,
|
||||
payload={
|
||||
"parent_type": parent_type,
|
||||
"parent_id": parent_id,
|
||||
"child_type": child_type,
|
||||
"child_id": child_id,
|
||||
},
|
||||
source_type="container_relation",
|
||||
source_id=rel.id,
|
||||
)
|
||||
|
||||
return rel
|
||||
@@ -143,19 +139,17 @@ async def detach_child(
|
||||
rel.deleted_at = func.now()
|
||||
await session.flush()
|
||||
|
||||
await emit_activity(
|
||||
await emit_event(
|
||||
session,
|
||||
activity_type="Remove",
|
||||
actor_uri="internal:system",
|
||||
object_type="rose:ContainerRelation",
|
||||
object_data={
|
||||
event_type="container.child_detached",
|
||||
aggregate_type="container_relation",
|
||||
aggregate_id=rel.id,
|
||||
payload={
|
||||
"parent_type": parent_type,
|
||||
"parent_id": parent_id,
|
||||
"child_type": child_type,
|
||||
"child_id": child_id,
|
||||
},
|
||||
source_type="container_relation",
|
||||
source_id=rel.id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -239,9 +239,6 @@ class StubFederationService:
|
||||
async def get_following(self, session, username, page=1, per_page=20):
|
||||
return [], 0
|
||||
|
||||
async def get_followers_paginated(self, session, username, page=1, per_page=20):
|
||||
return [], 0
|
||||
|
||||
async def accept_follow_response(self, session, local_username, remote_actor_url):
|
||||
pass
|
||||
|
||||
@@ -263,9 +260,6 @@ class StubFederationService:
|
||||
async def get_public_timeline(self, session, before=None, limit=20):
|
||||
return []
|
||||
|
||||
async def get_actor_timeline(self, session, remote_actor_id, before=None, limit=20):
|
||||
return []
|
||||
|
||||
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
|
||||
raise RuntimeError("FederationService not available")
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ async def upgrade_ots_proof(proof_bytes: bytes) -> tuple[bytes, bool]:
|
||||
"""
|
||||
# OpenTimestamps upgrade is done via the `ots` CLI or their calendar API.
|
||||
# For now, return the proof as-is with is_confirmed=False.
|
||||
# Calendar-based upgrade polling not yet implemented.
|
||||
# TODO: Implement calendar-based upgrade polling.
|
||||
return proof_bytes, False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user