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
|
||||||
|
|
||||||
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
|
## Structure
|
||||||
|
|
||||||
@@ -8,78 +8,53 @@ Shared infrastructure, models, contracts, services, and templates used by all fi
|
|||||||
shared/
|
shared/
|
||||||
db/
|
db/
|
||||||
base.py # SQLAlchemy declarative Base
|
base.py # SQLAlchemy declarative Base
|
||||||
session.py # Async session factory (get_session, register_db)
|
session.py # Async session factory (get_session)
|
||||||
models/ # Canonical domain models
|
models/ # Shared domain models
|
||||||
user.py # User
|
user.py # User
|
||||||
magic_link.py # MagicLink (auth tokens)
|
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)
|
kv.py # KeyValue (key-value store)
|
||||||
menu_item.py # MenuItem (deprecated — use MenuNode)
|
menu_item.py # MenuItem
|
||||||
menu_node.py # MenuNode (navigation tree)
|
|
||||||
container_relation.py # ContainerRelation (parent-child content)
|
|
||||||
ghost_membership_entities.py # GhostNewsletter, UserNewsletter
|
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/
|
infrastructure/
|
||||||
factory.py # create_base_app() — Quart app factory
|
factory.py # create_base_app() — Quart app factory
|
||||||
cart_identity.py # current_cart_identity() (user_id or session_id)
|
cart_identity.py # current_cart_identity() (user_id or session_id)
|
||||||
cart_loader.py # Cart data loader for context processors
|
cart_loader.py # Cart data loader for context processors
|
||||||
context.py # Jinja2 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
|
jinja_setup.py # Jinja2 template environment setup
|
||||||
urls.py # URL helpers (coop_url, market_url, etc.)
|
urls.py # URL helpers (coop_url, market_url, etc.)
|
||||||
user_loader.py # Load current user from session
|
user_loader.py # Load current user from session
|
||||||
http_utils.py # HTTP utility functions
|
http_utils.py # HTTP utility functions
|
||||||
events/
|
events/
|
||||||
bus.py # emit_activity(), register_activity_handler()
|
bus.py # emit_event(), register_handler()
|
||||||
processor.py # EventProcessor (polls ap_activities, runs handlers)
|
processor.py # EventProcessor (polls domain_events, runs handlers)
|
||||||
handlers/ # Shared activity handlers
|
browser/app/
|
||||||
container_handlers.py # Navigation rebuild on attach/detach
|
csrf.py # CSRF protection
|
||||||
login_handlers.py # Cart/entry adoption on login
|
errors.py # Error handlers
|
||||||
order_handlers.py # Order lifecycle events
|
middleware.py # Request/response middleware
|
||||||
ap_delivery_handler.py # AP activity delivery to follower inboxes (wildcard)
|
redis_cacher.py # Tag-based Redis page caching
|
||||||
utils/
|
authz.py # Authorization helpers
|
||||||
__init__.py
|
filters/ # Jinja2 template filters (currency, truncate, etc.)
|
||||||
calendar_helpers.py # Calendar period/entry utilities
|
utils/ # HTMX helpers, UTC time, parsing
|
||||||
http_signatures.py # RSA keypair generation, HTTP signature signing/verification
|
payments/sumup.py # SumUp checkout API integration
|
||||||
ipfs_client.py # Async IPFS client (add_bytes, add_json, pin_cid)
|
browser/templates/ # ~300 Jinja2 templates shared across all apps
|
||||||
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
|
|
||||||
config.py # YAML config loader
|
config.py # YAML config loader
|
||||||
|
containers.py # ContainerType, container_filter, content_filter helpers
|
||||||
log_config/setup.py # Logging configuration (JSON formatter)
|
log_config/setup.py # Logging configuration (JSON formatter)
|
||||||
|
utils.py # host_url and other shared utilities
|
||||||
static/ # Shared static assets (CSS, JS, images, FontAwesome)
|
static/ # Shared static assets (CSS, JS, images, FontAwesome)
|
||||||
editor/ # Koenig (Ghost) rich text editor build
|
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
|
## 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.
|
- **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, and the glue handler registry.
|
||||||
- **Service contracts:** Cross-domain communication via typed Protocols + frozen DTO dataclasses. Apps call `services.calendar.method()`, never import models from other domains.
|
- **Event bus:** `emit_event()` writes to `domain_events` table in the caller's transaction. `EventProcessor` polls and dispatches to registered handlers.
|
||||||
- **Service registry:** Typed singleton (`services.blog`, `.calendar`, `.market`, `.cart`, `.federation`). Apps wire their own domain + stubs for others via `register_domain_services()`.
|
- **Inter-app HTTP:** `internal_api.get/post("cart", "/internal/cart/summary")` for cross-app reads. URLs resolved from `app-config.yaml`.
|
||||||
- **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`.
|
|
||||||
- **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session.
|
- **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session.
|
||||||
|
|
||||||
## Alembic Migrations
|
## 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).
|
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
|
```bash
|
||||||
|
# From any app directory (shared/ must be on sys.path)
|
||||||
alembic -c shared/alembic.ini upgrade head
|
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 ALL models so Base.metadata sees every table
|
||||||
import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost*
|
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:
|
try:
|
||||||
__import__(_mod)
|
__import__(_mod)
|
||||||
except ImportError:
|
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,
|
self, session: AsyncSession, username: str,
|
||||||
) -> list[APFollowerDTO]: ...
|
) -> 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(
|
async def add_follower(
|
||||||
self, session: AsyncSession, username: str,
|
self, session: AsyncSession, username: str,
|
||||||
follower_acct: str, follower_inbox: str, follower_actor_url: 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,
|
before: datetime | None = None, limit: int = 20,
|
||||||
) -> list[TimelineItemDTO]: ...
|
) -> 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 ----------------------------------------------------------
|
# -- Local posts ----------------------------------------------------------
|
||||||
async def create_local_post(
|
async def create_local_post(
|
||||||
self, session: AsyncSession, actor_profile_id: int,
|
self, session: AsyncSession, actor_profile_id: int,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ _engine = create_async_engine(
|
|||||||
future=True,
|
future=True,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_size=0, # 0 = unlimited (NullPool equivalent for asyncpg)
|
pool_size=-1 # ned to look at this!!!
|
||||||
)
|
)
|
||||||
|
|
||||||
_Session = async_sessionmaker(
|
_Session = async_sessionmaker(
|
||||||
@@ -34,42 +34,43 @@ async def get_session():
|
|||||||
await sess.close()
|
await sess.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def register_db(app: Quart):
|
def register_db(app: Quart):
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def open_session():
|
async def open_session():
|
||||||
g.s = _Session()
|
g.s = _Session()
|
||||||
g.tx = await g.s.begin()
|
g.tx = await g.s.begin()
|
||||||
g.had_error = False
|
g.had_error = False
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
async def maybe_commit(response):
|
async def maybe_commit(response):
|
||||||
# Runs BEFORE bytes are sent.
|
# Runs BEFORE bytes are sent.
|
||||||
if not g.had_error and 200 <= response.status_code < 400:
|
if not g.had_error and 200 <= response.status_code < 400:
|
||||||
try:
|
try:
|
||||||
if hasattr(g, "tx"):
|
if hasattr(g, "tx"):
|
||||||
await g.tx.commit()
|
await g.tx.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'commit failed {e}')
|
print(f'commit failed {e}')
|
||||||
if hasattr(g, "tx"):
|
if hasattr(g, "tx"):
|
||||||
await g.tx.rollback()
|
await g.tx.rollback()
|
||||||
from quart import make_response
|
from quart import make_response
|
||||||
return await make_response("Commit failed", 500)
|
return await make_response("Commit failed", 500)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@app.teardown_request
|
@app.teardown_request
|
||||||
async def finish(exc):
|
async def finish(exc):
|
||||||
try:
|
try:
|
||||||
# If an exception occurred OR we didn't commit (still in txn), roll back.
|
# If an exception occurred OR we didn't commit (still in txn), roll back.
|
||||||
if hasattr(g, "s"):
|
if hasattr(g, "s"):
|
||||||
if exc is not None or g.s.in_transaction():
|
if exc is not None or g.s.in_transaction():
|
||||||
if hasattr(g, "tx"):
|
if hasattr(g, "tx"):
|
||||||
await g.tx.rollback()
|
await g.tx.rollback()
|
||||||
finally:
|
finally:
|
||||||
if hasattr(g, "s"):
|
if hasattr(g, "s"):
|
||||||
await g.s.close()
|
await g.s.close()
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
async def mark_error(e):
|
async def mark_error(e):
|
||||||
g.had_error = True
|
g.had_error = True
|
||||||
raise
|
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
|
from .processor import EventProcessor
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["emit_event", "register_handler", "EventProcessor"]
|
||||||
"emit_activity",
|
|
||||||
"register_activity_handler",
|
|
||||||
"get_activity_handlers",
|
|
||||||
"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
|
emit_event() writes to the domain_events table within the caller's existing
|
||||||
the caller's existing DB transaction — atomic with the domain change.
|
DB transaction — atomic with whatever domain change triggered the event.
|
||||||
|
|
||||||
register_activity_handler() registers async handler functions that the
|
register_handler() registers async handler functions that the EventProcessor
|
||||||
EventProcessor dispatches when processing pending activities.
|
will call when processing events of a given type.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
from collections import defaultdict
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from shared.models.federation import APActivity
|
from shared.models.domain_event import DomainEvent
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# handler signature: async def handler(event: DomainEvent, session: AsyncSession) -> None
|
||||||
# Activity-handler registry
|
HandlerFn = Callable[[DomainEvent, AsyncSession], Awaitable[None]]
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Handler signature: async def handler(activity: APActivity, session: AsyncSession) -> None
|
|
||||||
ActivityHandlerFn = Callable[[APActivity, AsyncSession], Awaitable[None]]
|
|
||||||
|
|
||||||
# Keyed by (activity_type, object_type). object_type="*" is wildcard.
|
_handlers: Dict[str, List[HandlerFn]] = defaultdict(list)
|
||||||
_activity_handlers: Dict[Tuple[str, str], List[ActivityHandlerFn]] = defaultdict(list)
|
|
||||||
|
|
||||||
|
|
||||||
def register_activity_handler(
|
async def emit_event(
|
||||||
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(
|
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
event_type: str,
|
||||||
activity_type: str,
|
aggregate_type: str,
|
||||||
actor_uri: str,
|
aggregate_id: int,
|
||||||
object_type: str,
|
payload: Dict[str, Any] | None = None,
|
||||||
object_data: dict | None = None,
|
) -> DomainEvent:
|
||||||
source_type: str | None = None,
|
|
||||||
source_id: int | None = None,
|
|
||||||
visibility: str = "internal",
|
|
||||||
actor_profile_id: int | None = None,
|
|
||||||
) -> APActivity:
|
|
||||||
"""
|
"""
|
||||||
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
|
Call this inside your service function, using the same session that
|
||||||
domain change. The activity and the change commit together.
|
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()}"
|
event = DomainEvent(
|
||||||
|
event_type=event_type,
|
||||||
activity = APActivity(
|
aggregate_type=aggregate_type,
|
||||||
activity_id=activity_uri,
|
aggregate_id=aggregate_id,
|
||||||
activity_type=activity_type,
|
payload=payload or {},
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
session.add(activity)
|
session.add(event)
|
||||||
await session.flush()
|
await session.flush() # assign event.id
|
||||||
return activity
|
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():
|
def register_shared_handlers():
|
||||||
@@ -6,4 +6,5 @@ def register_shared_handlers():
|
|||||||
import shared.events.handlers.container_handlers # noqa: F401
|
import shared.events.handlers.container_handlers # noqa: F401
|
||||||
import shared.events.handlers.login_handlers # noqa: F401
|
import shared.events.handlers.login_handlers # noqa: F401
|
||||||
import shared.events.handlers.order_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
|
import shared.events.handlers.ap_delivery_handler # noqa: F401
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Deliver AP activities to remote followers.
|
"""Deliver AP activities to remote followers.
|
||||||
|
|
||||||
Registered as a wildcard handler — fires for every activity. Skips
|
On ``federation.activity_created`` → load activity + actor + followers →
|
||||||
non-public activities and those without an actor profile.
|
sign with HTTP Signatures → POST to each follower inbox.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import httpx
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models.federation import ActorProfile, APActivity, APFollower
|
||||||
from shared.services.registry import services
|
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"
|
object_id = activity.activity_id + "/object"
|
||||||
|
|
||||||
if activity.activity_type == "Delete":
|
if activity.activity_type == "Delete":
|
||||||
|
# Delete: object is a Tombstone with just id + type
|
||||||
obj.setdefault("id", object_id)
|
obj.setdefault("id", object_id)
|
||||||
obj.setdefault("type", "Tombstone")
|
obj.setdefault("type", "Tombstone")
|
||||||
else:
|
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("id", object_id)
|
||||||
obj.setdefault("type", activity.object_type)
|
obj.setdefault("type", activity.object_type)
|
||||||
obj.setdefault("attributedTo", actor_url)
|
obj.setdefault("attributedTo", actor_url)
|
||||||
@@ -102,20 +105,30 @@ async def _deliver_to_inbox(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
async def on_activity_created(event: DomainEvent, session: AsyncSession) -> None:
|
||||||
"""Deliver a public activity to all followers of its actor."""
|
"""Deliver a newly created activity to all followers."""
|
||||||
import os
|
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"):
|
if not services.has("federation"):
|
||||||
return
|
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")
|
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
|
# Load actor with private key
|
||||||
actor = (
|
actor = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -123,7 +136,7 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
|||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if not actor or not actor.private_key_pem:
|
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
|
return
|
||||||
|
|
||||||
# Load followers
|
# Load followers
|
||||||
@@ -134,13 +147,14 @@ async def on_any_activity(activity: APActivity, session: AsyncSession) -> None:
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
if not followers:
|
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
|
return
|
||||||
|
|
||||||
# Build activity JSON
|
# Build activity JSON
|
||||||
activity_json = _build_activity_json(activity, actor, domain)
|
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}
|
inboxes = {f.follower_inbox for f in followers if f.follower_inbox}
|
||||||
|
|
||||||
log.info(
|
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)
|
await _deliver_to_inbox(client, inbox_url, activity_json, actor, domain)
|
||||||
|
|
||||||
|
|
||||||
# Wildcard: fires for every activity
|
register_handler("federation.activity_created", on_activity_created)
|
||||||
register_activity_handler("*", on_any_activity)
|
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from shared.events import register_activity_handler
|
from shared.events import register_handler
|
||||||
from shared.models.federation import APActivity
|
from shared.models.domain_event import DomainEvent
|
||||||
from shared.services.navigation import rebuild_navigation
|
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)
|
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)
|
await rebuild_navigation(session)
|
||||||
|
|
||||||
|
|
||||||
register_activity_handler("Add", on_child_attached, object_type="rose:ContainerRelation")
|
register_handler("container.child_attached", on_child_attached)
|
||||||
register_activity_handler("Remove", on_child_detached, object_type="rose:ContainerRelation")
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from shared.events import register_activity_handler
|
from shared.events import register_handler
|
||||||
from shared.models.federation import APActivity
|
from shared.models.domain_event import DomainEvent
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
async def on_user_logged_in(activity: APActivity, session: AsyncSession) -> None:
|
async def on_user_logged_in(event: DomainEvent, session: AsyncSession) -> None:
|
||||||
data = activity.object_data
|
payload = event.payload
|
||||||
user_id = data["user_id"]
|
user_id = payload["user_id"]
|
||||||
session_id = data["session_id"]
|
session_id = payload["session_id"]
|
||||||
|
|
||||||
|
# Adopt cart items (if cart service is registered)
|
||||||
if services.has("cart"):
|
if services.has("cart"):
|
||||||
await services.cart.adopt_cart_for_user(session, user_id, session_id)
|
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"):
|
if services.has("calendar"):
|
||||||
await services.calendar.adopt_entries_for_user(session, user_id, session_id)
|
await services.calendar.adopt_entries_for_user(session, user_id, session_id)
|
||||||
await services.calendar.adopt_tickets_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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from shared.events import register_activity_handler
|
from shared.events import register_handler
|
||||||
from shared.models.federation import APActivity
|
from shared.models.domain_event import DomainEvent
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def on_order_created(activity: APActivity, session: AsyncSession) -> None:
|
async def on_order_created(event: DomainEvent, session: AsyncSession) -> None:
|
||||||
log.info("order.created: order_id=%s", activity.object_data.get("order_id"))
|
log.info("order.created: order_id=%s", event.payload.get("order_id"))
|
||||||
|
|
||||||
|
|
||||||
async def on_order_paid(activity: APActivity, session: AsyncSession) -> None:
|
async def on_order_paid(event: DomainEvent, session: AsyncSession) -> None:
|
||||||
log.info("order.paid: order_id=%s", activity.object_data.get("order_id"))
|
log.info("order.paid: order_id=%s", event.payload.get("order_id"))
|
||||||
|
|
||||||
|
|
||||||
register_activity_handler("Create", on_order_created, object_type="rose:Order")
|
register_handler("order.created", on_order_created)
|
||||||
register_activity_handler("rose:OrderPaid", on_order_paid)
|
register_handler("order.paid", on_order_paid)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Event processor — polls the ap_activities table and dispatches to registered
|
Event processor — polls the domain_events outbox table and dispatches
|
||||||
activity handlers.
|
to registered handlers.
|
||||||
|
|
||||||
Runs as an asyncio background task within each app process.
|
Runs as an asyncio background task within each app process.
|
||||||
Uses SELECT ... FOR UPDATE SKIP LOCKED for safe concurrent processing.
|
Uses SELECT ... FOR UPDATE SKIP LOCKED for safe concurrent processing.
|
||||||
@@ -11,16 +11,16 @@ import asyncio
|
|||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from shared.db.session import get_session
|
from shared.db.session import get_session
|
||||||
from shared.models.federation import APActivity
|
from shared.models.domain_event import DomainEvent
|
||||||
from .bus import get_activity_handlers
|
from .bus import get_handlers
|
||||||
|
|
||||||
|
|
||||||
class EventProcessor:
|
class EventProcessor:
|
||||||
"""Background event processor that polls the ap_activities table."""
|
"""Background event processor that polls the outbox table."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -64,52 +64,54 @@ class EventProcessor:
|
|||||||
await asyncio.sleep(self._poll_interval)
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
|
||||||
async def _process_batch(self) -> int:
|
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
|
processed = 0
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
|
# FOR UPDATE SKIP LOCKED: safe for concurrent processors
|
||||||
stmt = (
|
stmt = (
|
||||||
select(APActivity)
|
select(DomainEvent)
|
||||||
.where(
|
.where(
|
||||||
APActivity.process_state == "pending",
|
DomainEvent.state == "pending",
|
||||||
APActivity.process_attempts < APActivity.process_max_attempts,
|
DomainEvent.attempts < DomainEvent.max_attempts,
|
||||||
)
|
)
|
||||||
.order_by(APActivity.created_at)
|
.order_by(DomainEvent.created_at)
|
||||||
.limit(self._batch_size)
|
.limit(self._batch_size)
|
||||||
.with_for_update(skip_locked=True)
|
.with_for_update(skip_locked=True)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
activities = result.scalars().all()
|
events = result.scalars().all()
|
||||||
|
|
||||||
for activity in activities:
|
for event in events:
|
||||||
await self._process_one(session, activity)
|
await self._process_one(session, event)
|
||||||
processed += 1
|
processed += 1
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return processed
|
return processed
|
||||||
|
|
||||||
async def _process_one(self, session: AsyncSession, activity: APActivity) -> None:
|
async def _process_one(self, session: AsyncSession, event: DomainEvent) -> None:
|
||||||
"""Run all handlers for a single activity."""
|
"""Run all handlers for a single event."""
|
||||||
handlers = get_activity_handlers(activity.activity_type, activity.object_type)
|
handlers = get_handlers(event.event_type)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
activity.process_state = "processing"
|
event.state = "processing"
|
||||||
activity.process_attempts += 1
|
event.attempts += 1
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if not handlers:
|
if not handlers:
|
||||||
activity.process_state = "completed"
|
# No handlers registered — mark completed (nothing to do)
|
||||||
activity.processed_at = now
|
event.state = "completed"
|
||||||
|
event.processed_at = now
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
await handler(activity, session)
|
await handler(event, session)
|
||||||
activity.process_state = "completed"
|
event.state = "completed"
|
||||||
activity.processed_at = now
|
event.processed_at = now
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
activity.process_error = f"{exc.__class__.__name__}: {exc}"
|
event.last_error = f"{exc.__class__.__name__}: {exc}"
|
||||||
if activity.process_attempts >= activity.process_max_attempts:
|
if event.attempts >= event.max_attempts:
|
||||||
activity.process_state = "failed"
|
event.state = "failed"
|
||||||
activity.processed_at = now
|
event.processed_at = now
|
||||||
else:
|
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:
|
def login_url(next_url: str = "") -> str:
|
||||||
# Auth lives in blog (coop) for now. Set AUTH_APP=federation to switch.
|
# 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")
|
auth_app = os.getenv("AUTH_APP", "coop")
|
||||||
base = app_url(auth_app, "/auth/login/")
|
base = app_url(auth_app, "/auth/login/")
|
||||||
params: list[str] = []
|
|
||||||
if next_url:
|
if next_url:
|
||||||
params.append(f"next={quote(next_url, safe='')}")
|
return f"{base}?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 base
|
return base
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .ghost_membership_entities import (
|
|||||||
GhostNewsletter, UserNewsletter,
|
GhostNewsletter, UserNewsletter,
|
||||||
GhostTier, GhostSubscription,
|
GhostTier, GhostSubscription,
|
||||||
)
|
)
|
||||||
|
from .domain_event import DomainEvent
|
||||||
|
|
||||||
from .ghost_content import Tag, Post, Author, PostAuthor, PostTag, PostLike
|
from .ghost_content import Tag, Post, Author, PostAuthor, PostTag, PostLike
|
||||||
from .page_config import PageConfig
|
from .page_config import PageConfig
|
||||||
from .order import Order, OrderItem
|
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):
|
class APActivity(Base):
|
||||||
"""An ActivityPub activity (local or remote).
|
"""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'``.
|
|
||||||
"""
|
|
||||||
__tablename__ = "ap_activities"
|
__tablename__ = "ap_activities"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||||
activity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
activity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
actor_profile_id: Mapped[int | None] = mapped_column(
|
actor_profile_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=True,
|
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
|
||||||
)
|
)
|
||||||
object_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
object_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
object_data: Mapped[dict | None] = mapped_column(JSONB, 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(),
|
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
|
# Relationships
|
||||||
actor_profile = relationship("ActorProfile", back_populates="activities")
|
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_actor", "actor_profile_id"),
|
||||||
Index("ix_ap_activity_source", "source_type", "source_id"),
|
Index("ix_ap_activity_source", "source_type", "source_id"),
|
||||||
Index("ix_ap_activity_published", "published"),
|
Index("ix_ap_activity_published", "published"),
|
||||||
Index("ix_ap_activity_process", "process_state"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from shared.db.base import Base
|
|||||||
|
|
||||||
|
|
||||||
class MenuItem(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"
|
__tablename__ = "menu_items"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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))
|
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
|
||||||
return entries_by_post
|
return entries_by_post
|
||||||
|
|
||||||
# -- writes ---------------------------------------------------------------
|
# -- writes (absorb glue lifecycle) ---------------------------------------
|
||||||
|
|
||||||
async def adopt_entries_for_user(
|
async def adopt_entries_for_user(
|
||||||
self, session: AsyncSession, user_id: int, session_id: str,
|
self, session: AsyncSession, user_id: int, session_id: str,
|
||||||
|
|||||||
@@ -183,21 +183,16 @@ class SqlFederationService:
|
|||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
actor_url = f"https://{domain}/users/{username}"
|
|
||||||
|
|
||||||
activity = APActivity(
|
activity = APActivity(
|
||||||
activity_id=activity_uri,
|
activity_id=activity_uri,
|
||||||
activity_type=activity_type,
|
activity_type=activity_type,
|
||||||
actor_profile_id=actor.id,
|
actor_profile_id=actor.id,
|
||||||
actor_uri=actor_url,
|
|
||||||
object_type=object_type,
|
object_type=object_type,
|
||||||
object_data=object_data,
|
object_data=object_data,
|
||||||
published=now,
|
published=now,
|
||||||
is_local=True,
|
is_local=True,
|
||||||
source_type=source_type,
|
source_type=source_type,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
visibility="public",
|
|
||||||
process_state="pending",
|
|
||||||
)
|
)
|
||||||
session.add(activity)
|
session.add(activity)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -213,7 +208,7 @@ class SqlFederationService:
|
|||||||
],
|
],
|
||||||
"id": activity_uri,
|
"id": activity_uri,
|
||||||
"type": activity_type,
|
"type": activity_type,
|
||||||
"actor": actor_url,
|
"actor": f"https://{domain}/users/{username}",
|
||||||
"published": now.isoformat(),
|
"published": now.isoformat(),
|
||||||
"object": {
|
"object": {
|
||||||
"type": object_type,
|
"type": object_type,
|
||||||
@@ -226,6 +221,21 @@ class SqlFederationService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # IPFS failure is non-fatal
|
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)
|
return _activity_to_dto(activity)
|
||||||
|
|
||||||
# -- Queries --------------------------------------------------------------
|
# -- Queries --------------------------------------------------------------
|
||||||
@@ -366,65 +376,6 @@ class SqlFederationService:
|
|||||||
)
|
)
|
||||||
return result.rowcount > 0
|
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 --------------------------------------------------------
|
# -- Remote actors --------------------------------------------------------
|
||||||
|
|
||||||
async def get_or_fetch_remote_actor(
|
async def get_or_fetch_remote_actor(
|
||||||
@@ -1015,46 +966,6 @@ class SqlFederationService:
|
|||||||
))
|
))
|
||||||
return items
|
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 ----------------------------------------------------------
|
# -- Local posts ----------------------------------------------------------
|
||||||
|
|
||||||
async def create_local_post(
|
async def create_local_post(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Inline federation publication — called at write time, not via async handler.
|
"""Inline federation publication — called at write time, not via async handler.
|
||||||
|
|
||||||
The originating service calls try_publish() directly, which creates the
|
Replaces the old pattern where emit_event("post.published") → async handler →
|
||||||
APActivity (with process_state='pending') in the same DB transaction.
|
publish_activity(). Now the originating service calls try_publish() directly,
|
||||||
The EventProcessor picks it up and the delivery wildcard handler POSTs
|
which creates the APActivity in the same DB transaction. AP delivery
|
||||||
to follower inboxes.
|
(federation.activity_created → inbox POST) stays async.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
from shared.models.container_relation import ContainerRelation
|
||||||
|
|
||||||
|
|
||||||
@@ -40,19 +40,17 @@ async def attach_child(
|
|||||||
if label is not None:
|
if label is not None:
|
||||||
existing.label = label
|
existing.label = label
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await emit_activity(
|
await emit_event(
|
||||||
session,
|
session,
|
||||||
activity_type="Add",
|
event_type="container.child_attached",
|
||||||
actor_uri="internal:system",
|
aggregate_type="container_relation",
|
||||||
object_type="rose:ContainerRelation",
|
aggregate_id=existing.id,
|
||||||
object_data={
|
payload={
|
||||||
"parent_type": parent_type,
|
"parent_type": parent_type,
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
"child_type": child_type,
|
"child_type": child_type,
|
||||||
"child_id": child_id,
|
"child_id": child_id,
|
||||||
},
|
},
|
||||||
source_type="container_relation",
|
|
||||||
source_id=existing.id,
|
|
||||||
)
|
)
|
||||||
return existing
|
return existing
|
||||||
# Already attached and active — no-op
|
# Already attached and active — no-op
|
||||||
@@ -79,19 +77,17 @@ async def attach_child(
|
|||||||
session.add(rel)
|
session.add(rel)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
await emit_activity(
|
await emit_event(
|
||||||
session,
|
session,
|
||||||
activity_type="Add",
|
event_type="container.child_attached",
|
||||||
actor_uri="internal:system",
|
aggregate_type="container_relation",
|
||||||
object_type="rose:ContainerRelation",
|
aggregate_id=rel.id,
|
||||||
object_data={
|
payload={
|
||||||
"parent_type": parent_type,
|
"parent_type": parent_type,
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
"child_type": child_type,
|
"child_type": child_type,
|
||||||
"child_id": child_id,
|
"child_id": child_id,
|
||||||
},
|
},
|
||||||
source_type="container_relation",
|
|
||||||
source_id=rel.id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return rel
|
return rel
|
||||||
@@ -143,19 +139,17 @@ async def detach_child(
|
|||||||
rel.deleted_at = func.now()
|
rel.deleted_at = func.now()
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
await emit_activity(
|
await emit_event(
|
||||||
session,
|
session,
|
||||||
activity_type="Remove",
|
event_type="container.child_detached",
|
||||||
actor_uri="internal:system",
|
aggregate_type="container_relation",
|
||||||
object_type="rose:ContainerRelation",
|
aggregate_id=rel.id,
|
||||||
object_data={
|
payload={
|
||||||
"parent_type": parent_type,
|
"parent_type": parent_type,
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
"child_type": child_type,
|
"child_type": child_type,
|
||||||
"child_id": child_id,
|
"child_id": child_id,
|
||||||
},
|
},
|
||||||
source_type="container_relation",
|
|
||||||
source_id=rel.id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -239,9 +239,6 @@ class StubFederationService:
|
|||||||
async def get_following(self, session, username, page=1, per_page=20):
|
async def get_following(self, session, username, page=1, per_page=20):
|
||||||
return [], 0
|
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):
|
async def accept_follow_response(self, session, local_username, remote_actor_url):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -263,9 +260,6 @@ class StubFederationService:
|
|||||||
async def get_public_timeline(self, session, before=None, limit=20):
|
async def get_public_timeline(self, session, before=None, limit=20):
|
||||||
return []
|
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):
|
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
|
||||||
raise RuntimeError("FederationService not available")
|
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.
|
# OpenTimestamps upgrade is done via the `ots` CLI or their calendar API.
|
||||||
# For now, return the proof as-is with is_confirmed=False.
|
# 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
|
return proof_bytes, False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user