Compare commits

...

62 Commits

Author SHA1 Message Date
giles
9ab4b7b3fe Prevent circular fragment fetching between apps
fetch_fragment() auto-returns "" when called inside a fragment request
(detected via X-Fragment-Request header). This prevents deadlocks when
e.g. blog fetches cart-mini from cart, and cart's context processor
fetches nav-tree from blog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:17:47 +00:00
giles
20d3ff8425 Make fragment failures raise by default instead of silent degradation
FragmentError raised on network errors or non-200 responses when
required=True (default). Logs at ERROR level. Pass required=False
for optional fragments that should degrade gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:03:04 +00:00
giles
cf2e2ba1db Remove cross-domain template dependencies from shared infrastructure
- macros/search.html: shared search input macros (mobile + desktop)
- macros/cart_icon.html: shared cart icon/badge macro (count param, no DB)
- macros/layout.html: inline hamburger icon, use shared search macro
- _oob.html: use cart_mini_html fragment slot instead of cart template import
- db/session.py: guard teardown rollback against committed/dead sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:28:09 +00:00
giles
5518c95237 Bound connection pool to prevent max_connections exhaustion
pool_size=5, max_overflow=10 per app (6 apps = 90 max connections).
Previously pool_size=0 meant unlimited connections, causing
asyncpg.TooManyConnectionsError under concurrent load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:08:00 +00:00
giles
322ae481ee Move domain templates to owning apps, delete dead widgets (Phase 6)
Domain-specific templates moved from shared/browser/templates/ to
each app's templates/ dir. 38 domain-free infrastructure templates
remain (root layout, macros, oob, mobile, social, sentinel).
Removed fragment fallbacks from root header.
Deleted _widgets/ (dead code since Phase 4-5).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:37:08 +00:00
giles
65c4989d08 Replace account widgets with fragment composition (Phase 5)
Account nav links and page panels (tickets, bookings) now fetched
as HTML fragments from events app instead of using widget registry.
All three widget registration functions are now no-ops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:56:29 +00:00
giles
d2e07e047e Replace container nav/card widgets with fragment composition (Phase 4)
Templates now consume container_nav_html and card_widgets_html from
fragment fetches instead of iterating widgets.container_nav / container_cards.
OOB nav template renders entry/calendar links directly from data.
Calendar and market widget registrations removed (account widgets remain).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:32:48 +00:00
giles
ab674ada31 Fix nav links: map all app slugs to correct app URLs
Previously only 'cart' was mapped — slugs like 'federation', 'market',
'events', 'account' incorrectly linked to blog_url(). Now all app
slugs resolve to their proper cross-app URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:49:04 +00:00
giles
7e650a0ee3 Add nav-tree, auth-menu, cart-mini fragment slots to shared header
The shared _header.html template now checks for pre-fetched fragment
HTML (nav_tree_html, auth_menu_html, cart_mini_html) before falling
back to local includes. This lets consumer apps render cross-app
UI via the fragment composition system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:34:45 +00:00
giles
0d40dfaeca Rename product blueprint URL param slug → product_slug in templates
Avoids collision with app-level url_value_preprocessor that pops
"slug" for page-level routing. All url_for() calls for product
endpoints now use product_slug= instead of slug=.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:24:49 +00:00
giles
2a9dfaa749 Skip auth state check on /internal/ paths (fragment endpoints)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:04:25 +00:00
giles
b882770828 Add fragment composition infrastructure for micro-frontend UI
New HTTP client (fragments.py) fetches HTML fragments from other apps
over the Docker network, with Redis caching and graceful degradation.
Jinja global `fragment()` available in all templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:26:08 +00:00
giles
e7d180912b Instant logout detection: skip grant cache when did_auth cleared
When account logs out it deletes did_auth:{device_id} from Redis.
If that key is gone, bypass the 60s grant cache and re-check the
DB immediately, detecting the revoked grant on the first request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:21 +00:00
giles
beac1b3dab Add external delivery handler for cross-service AP activities
Delivers rose:DeviceAuth activities to configured external inboxes
(e.g. artdag) via signed HTTP POST. Config via EXTERNAL_INBOXES env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:21:50 +00:00
giles
25ac3db644 Add artdag_url() helper for cross-app navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:10 +00:00
giles
d9b51b1f84 Deliver per (inbox, domain) — federation actor gets all posts too
Add app_domain to APDeliveryLog so the same activity can be delivered
to the same inbox under different actor identities (blog + federation).
2026-02-23 21:51:19 +00:00
giles
61ad2db2f3 Backfill only current posts: skip deleted, use latest update data
Previously backfill_follower sent all Create activities regardless
of whether they were later Deleted or Updated. Now it excludes
deleted sources and applies the latest Update's object_data so
new followers see the current version of each post.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:36:30 +00:00
giles
dd9cb9f5f2 Add debug logging to Accept delivery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:15:57 +00:00
giles
bbc376aebc Rewrite all federation-domain URLs in object_data for per-app delivery
Mastodon requires object IDs to match the actor's domain. The
object_data stored in DB uses federation.rose-ash.com but per-app
delivery uses blog.rose-ash.com etc. Now rewrites id and
attributedTo in object_data, not just the activity-level fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:05:50 +00:00
giles
14fbd59e7b Fix activity ID domain mismatch in per-app delivery
Mastodon requires the activity ID host to match the actor host.
The stored activity_id uses federation.rose-ash.com but per-app
delivery sends actor as blog.rose-ash.com etc.  Rewrite the
activity ID host to match the delivery domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:36:56 +00:00
giles
856ba94f3b Exempt AP paths from auth state check
/.well-known/, /users/, /nodeinfo/ now skip the prompt=none
OAuth redirect so ActivityPub endpoints work for unauthenticated
remote servers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:19:25 +00:00
giles
1bb19c96ed Fix per-app AP delivery, NULL uniqueness, and reverse discovery
- Delivery handler now signs/delivers using the per-app domain that
  matches the follower's subscription (not always federation domain)
- app_domain is NOT NULL with default 'federation' (sentinel replaces
  NULL to avoid uniqueness constraint edge case)
- Aggregate actor advertises per-app actors via alsoKnownAs
- Migration backfills existing NULL rows to 'federation'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:25:24 +00:00
giles
f2262f702b Add per-app ActivityPub actors via shared AP blueprint
Each AP-enabled app (blog, market, events, federation) now serves its
own webfinger, actor profile, inbox, outbox, and followers endpoints.
Per-app actors are virtual projections of the same ActorProfile/keypair,
scoped by APFollower.app_domain and APActivity.origin_app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:02:30 +00:00
giles
001cbffd74 Add minimal home page templates — content only, no title bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:32:25 +00:00
giles
38233279a2 Rename blog.home → blog.index in not_found template
The blog index moved from / to /index; homepage now shows a Ghost page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:54:02 +00:00
giles
9cb8cf9e1d Add at-least-once delivery + idempotent federation handler
- EventProcessor now recovers stuck "processing" activities back to
  "pending" after 5 minutes (handles process crashes)
- New ap_delivery_log table records successful inbox deliveries
- Federation delivery handler checks the log before sending, so
  retries skip already-delivered inboxes
- Together these give at-least-once + idempotent semantics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:16:55 +00:00
giles
8951a62b90 Add NOTIFY/LISTEN wake-up to event processor
emit_activity() now fires NOTIFY ap_activity_pending inside the
caller's transaction (delivered on commit).  EventProcessor maintains
a dedicated asyncpg LISTEN connection and wakes the poll loop
immediately, dropping latency from ~2 s to sub-100 ms.  The fixed-
interval poll remains as a safety-net fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:35:27 +00:00
giles
7b878a501b Add migration for device_id column on oauth_grants
The column was added to the create_table migration after it had already
been applied, so the live DB was missing it.  This new migration adds
the column and index separately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:25:10 +00:00
giles
748d28e657 Set blog_did = account_did — one device identity across all apps
Callback adopts account's device_id by overwriting g.device_id,
so the factory after_request sets {app}_did cookie to account's value.
Simplifies factory check: g.device_id IS the account_did, no need
to read _account_did from session separately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:11:24 +00:00
giles
cad528d732 Device-id SSO: account sets did, signals login via Redis
- Factory: set {name}_did cookie for all apps (including account)
  via before_request/after_request hooks (g.device_id always available)
- Factory: _check_auth_state checks did_auth:{account_did} in Redis
  to override stale "not logged in" cache when account login detected
- OAuth: removed _ensure_device_cookie (moved to factory), callback
  stores account_did from authorize redirect in session
- OAuth: login uses g.device_id, logout clears _account_did

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:57:40 +00:00
giles
c4590d1442 Replace propagation chain + check-device with prompt=none OAuth handshake
Client apps now do a silent OAuth round-trip (prompt=none) to account on
first visit. If user is logged in on account, they get silently logged in.
If not, the result is cached (5 min) to avoid repeated handshakes.

Grant verification now uses direct DB query instead of aiohttp HTTP calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:40:58 +00:00
giles
38a2023ca3 Add aiohttp to requirements for internal auth checks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:05:24 +00:00
giles
de93dfdc73 Device cookie + internal endpoint for auth state detection
Each client app sets a persistent first-party device cookie ({app}_did).
On each request:
- Logged in: verify grant via account internal endpoint (cached 60s)
- Not logged in + device cookie: check-device endpoint detects if user
  logged in since last grant revocation → triggers OAuth automatically
No cross-domain cookies. No propagation chain. Each app checks independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:50:43 +00:00
giles
6bb26522a1 Add OAuth grants for per-device session revocation
- OAuthGrant model tracks each client authorization, tied to the
  account session (issuer_session) that issued it
- OAuth authorize creates grant + code together
- Client apps store grant_token in session, verify via account's
  internal /auth/internal/verify-grant endpoint (Redis-cached 60s)
- Account logout revokes only grants from that device's session
- Replaces iframe-based logout with server-side grant revocation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:08 +00:00
giles
9a637c6227 sso-clear returns 204 for iframe-based logout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:21:14 +00:00
giles
a93a456ac5 Remove sso_hint cookie, add sso-clear logout chain
sso_hint on .rose-ash.com was blocked by Safari ITP — the exact
problem we're solving. Replaced with redirect chain: account logout
chains through each client app's /auth/sso-clear to clear all
first-party sessions without any cross-domain cookies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:17:22 +00:00
giles
223491fad5 SSO revocation: clear local session when sso_hint cookie is gone
When account logs out and deletes sso_hint, client apps now detect
the missing cookie and clear their local session on next request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:15:35 +00:00
giles
dfc41ada7d Make account the OAuth authorization server instead of federation
All client apps (including federation) now redirect to account for OAuth.
Factory excludes account from OAuth client blueprint registration.
SSO logout chains through account instead of federation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:53:34 +00:00
giles
60cd08adc9 Add /auth/clear endpoint to reset session cookies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:44:47 +00:00
giles
d50f01d41f Logout: redirect through federation sso-logout to clear all sessions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:31:44 +00:00
giles
bfd8d55f27 Silent SSO via sso_hint cookie
- Federation sets sso_hint=1 on .rose-ash.com after magic link login
- Client apps: before_request checks sso_hint, triggers silent OAuth
  once per session (sso_checked flag prevents loops)
- Logout clears sso_hint cookie on all apps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:23:26 +00:00
giles
d0a5170cd9 Fix logout: redirect to blog home to avoid re-auth loop
Account's / requires login, so redirecting there after logout
triggers silent OAuth re-authentication. Blog home is safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:15:14 +00:00
giles
8323c45711 Fix logout: post to local /auth/logout/ not federation
Each app has its own session and OAuth logout endpoint now.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:03 +00:00
giles
16df62e2c4 Sign-in button → account app, clear old shared cookie
- Nav sign-in links point to account_url('/') instead of login_url()
- After-request hook clears old blog_session cookie on .rose-ash.com
  (prevents collision with new per-app first-party cookies)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:56:56 +00:00
giles
ea35e040e7 Fix OAuth authorize URL: include /auth prefix
The federation auth blueprint is mounted at /auth, so the authorize
endpoint is /auth/oauth/authorize, not /oauth/authorize.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:25:46 +00:00
giles
46f44f6171 OAuth SSO infrastructure + account app support
- OAuthCode model + migration for authorization code flow
- OAuth client blueprint (auto-registered for non-federation apps)
- Per-app first-party session cookies (fixes Safari ITP)
- /oauth/authorize endpoint support in URL helpers
- account_url() helper + Jinja global
- Templates: federation_url('/auth/...') → account_url('/...')
- Widget registry: account page links use account_url

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:55:27 +00:00
giles
326b380135 Fix root top-bar account link to point to federation
The logged-in user links in _full_user.html (desktop + mobile)
still used blog_url — now they use federation_url to match
the account page migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:07:38 +00:00
giles
ea8e7da9d4 Move account page URLs from blog to federation
Auth templates and widget nav links now point to
federation_url instead of blog_url, co-locating the
account UI with the auth system in the federation app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:00:44 +00:00
giles
bd18d0befc Route auth to federation: login_url default, logout URL, federation_url global
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:41:18 +00:00
giles
5bed4a6c78 Update README: coop_url → blog_url
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:35:16 +00:00
giles
798087de9a Rename coop to blog throughout codebase
- coop_url() → blog_url(), AUTH_APP default → "blog"
- Session cookie: coop_session → blog_session
- Config keys: coop_root/coop_title → market_root/market_title
- All Jinja templates: coop_url → blog_url, coop_title → market_title
- Template blocks: coop-child-header → blog-child-header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:32:46 +00:00
giles
cc22b21b18 Rename coop.rose-ash.com to blog.rose-ash.com in comment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:27:54 +00:00
giles
f085d4a8d0 Add search_actors to FederationService for paginated actor search
Fuzzy ILIKE search across remote actors and local profiles, with
WebFinger resolution for @user@domain queries. Supports page-based
pagination for infinite scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:18:43 +00:00
giles
b16ba34b40 Add list_marketplaces to MarketService protocol, impl, and stub
Paginated query for market listings — supports optional container filtering
and returns (dtos, has_more) for infinite scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:29:14 +00:00
giles
16e4d3aa57 Make upcoming_entries_for_container work without container filter
When container_type/container_id are None, returns all upcoming
confirmed entries across all calendars (for global event listings).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:04:55 +00:00
giles
6e438dbfdc Add upcoming_entries_for_container to CalendarService
New paginated query for upcoming confirmed entries across all calendars
belonging to a container (page). Used by the events page summary view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:28:18 +00:00
giles
7316dc6eac Add 'updated' timestamp to Update activity objects for Mastodon
Mastodon requires an updated field to process post edits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:41:18 +00:00
giles
a3a41dbefd Allow repeated Update activities for post edits
The dedup guard was blocking legitimate edits after the first Update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:36:15 +00:00
giles
30b5a1438b Change AP_DOMAIN default to federation.rose-ash.com
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:28 +00:00
giles
0e89dbee55 Make origin_app migration idempotent
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:59:19 +00:00
giles
86ccfd25c5 Add origin_app to APActivity — apps only process their own activities
Each app's EventProcessor now filters by origin_app so apps don't steal
each other's pending activities. emit_activity() and publish_activity()
auto-detect the app name from Quart's current_app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:57:46 +00:00
giles
b42f5d63db Add debug logging to EventProcessor and activity handler registry
Logs which handlers are registered at startup and which handlers are
found/called when processing each activity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:49:37 +00:00
323 changed files with 2681 additions and 10975 deletions

View File

@@ -44,7 +44,7 @@ shared/
cart_loader.py # Cart data loader for context processors
context.py # Jinja2 context processors
jinja_setup.py # Jinja2 template environment setup
urls.py # URL helpers (coop_url, market_url, etc.)
urls.py # URL helpers (blog_url, market_url, etc.)
user_loader.py # Load current user from session
http_utils.py # HTTP utility functions
events/

View File

@@ -0,0 +1,35 @@
"""Add origin_app column to ap_activities
Revision ID: o5m3j9k1l2
Revises: n4l2i8j0k1
Create Date: 2026-02-22
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect as sa_inspect
revision = "o5m3j9k1l2"
down_revision = "n4l2i8j0k1"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa_inspect(conn)
columns = [c["name"] for c in inspector.get_columns("ap_activities")]
if "origin_app" not in columns:
op.add_column(
"ap_activities",
sa.Column("origin_app", sa.String(64), nullable=True),
)
# Index is idempotent with if_not_exists
op.create_index(
"ix_ap_activity_origin_app", "ap_activities", ["origin_app"],
if_not_exists=True,
)
def downgrade() -> None:
op.drop_index("ix_ap_activity_origin_app", table_name="ap_activities")
op.drop_column("ap_activities", "origin_app")

View File

@@ -0,0 +1,37 @@
"""Add oauth_codes table
Revision ID: p6n4k0l2m3
Revises: o5m3j9k1l2
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
revision = "p6n4k0l2m3"
down_revision = "o5m3j9k1l2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"oauth_codes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(128), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("client_id", sa.String(64), nullable=False),
sa.Column("redirect_uri", sa.String(512), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_oauth_code_code", "oauth_codes", ["code"], unique=True)
op.create_index("ix_oauth_code_user", "oauth_codes", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_oauth_code_user", table_name="oauth_codes")
op.drop_index("ix_oauth_code_code", table_name="oauth_codes")
op.drop_table("oauth_codes")

View File

@@ -0,0 +1,41 @@
"""Add oauth_grants table
Revision ID: q7o5l1m3n4
Revises: p6n4k0l2m3
"""
from alembic import op
import sqlalchemy as sa
revision = "q7o5l1m3n4"
down_revision = "p6n4k0l2m3"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"oauth_grants",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("token", sa.String(128), unique=True, nullable=False),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("client_id", sa.String(64), nullable=False),
sa.Column("issuer_session", sa.String(128), nullable=False),
sa.Column("device_id", sa.String(128), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_oauth_grant_token", "oauth_grants", ["token"], unique=True)
op.create_index("ix_oauth_grant_issuer", "oauth_grants", ["issuer_session"])
op.create_index("ix_oauth_grant_user", "oauth_grants", ["user_id"])
op.create_index("ix_oauth_grant_device", "oauth_grants", ["device_id", "client_id"])
# Add grant_token column to oauth_codes to link code → grant
op.add_column("oauth_codes", sa.Column("grant_token", sa.String(128), nullable=True))
def downgrade():
op.drop_column("oauth_codes", "grant_token")
op.drop_index("ix_oauth_grant_user", table_name="oauth_grants")
op.drop_index("ix_oauth_grant_issuer", table_name="oauth_grants")
op.drop_index("ix_oauth_grant_token", table_name="oauth_grants")
op.drop_table("oauth_grants")

View File

@@ -0,0 +1,29 @@
"""Add device_id column to oauth_grants
Revision ID: r8p6m2n4o5
Revises: q7o5l1m3n4
"""
from alembic import op
import sqlalchemy as sa
revision = "r8p6m2n4o5"
down_revision = "q7o5l1m3n4"
branch_labels = None
depends_on = None
def upgrade():
# device_id was added to the create_table migration after it had already
# run, so the column is missing from the live DB. Add it now.
op.add_column(
"oauth_grants",
sa.Column("device_id", sa.String(128), nullable=True),
)
op.create_index(
"ix_oauth_grant_device", "oauth_grants", ["device_id", "client_id"]
)
def downgrade():
op.drop_index("ix_oauth_grant_device", table_name="oauth_grants")
op.drop_column("oauth_grants", "device_id")

View File

@@ -0,0 +1,30 @@
"""Add ap_delivery_log table for idempotent federation delivery
Revision ID: s9q7n3o5p6
Revises: r8p6m2n4o5
"""
from alembic import op
import sqlalchemy as sa
revision = "s9q7n3o5p6"
down_revision = "r8p6m2n4o5"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"ap_delivery_log",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("activity_id", sa.Integer, sa.ForeignKey("ap_activities.id", ondelete="CASCADE"), nullable=False),
sa.Column("inbox_url", sa.String(512), nullable=False),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("activity_id", "inbox_url", name="uq_delivery_activity_inbox"),
)
op.create_index("ix_ap_delivery_activity", "ap_delivery_log", ["activity_id"])
def downgrade():
op.drop_index("ix_ap_delivery_activity", table_name="ap_delivery_log")
op.drop_table("ap_delivery_log")

View File

@@ -0,0 +1,51 @@
"""Add app_domain to ap_followers for per-app AP actors
Revision ID: t0r8n4o6p7
Revises: s9q7n3o5p6
"""
from alembic import op
import sqlalchemy as sa
revision = "t0r8n4o6p7"
down_revision = "s9q7n3o5p6"
branch_labels = None
depends_on = None
def upgrade():
# Add column as nullable first so we can backfill
op.add_column(
"ap_followers",
sa.Column("app_domain", sa.String(64), nullable=True),
)
# Backfill existing rows: all current followers are aggregate
op.execute("UPDATE ap_followers SET app_domain = 'federation' WHERE app_domain IS NULL")
# Now make it NOT NULL with a default
op.alter_column(
"ap_followers", "app_domain",
nullable=False, server_default="federation",
)
# Replace old unique constraint with one that includes app_domain
op.drop_constraint("uq_follower_acct", "ap_followers", type_="unique")
op.create_unique_constraint(
"uq_follower_acct_app",
"ap_followers",
["actor_profile_id", "follower_acct", "app_domain"],
)
op.create_index(
"ix_ap_follower_app_domain",
"ap_followers",
["actor_profile_id", "app_domain"],
)
def downgrade():
op.drop_index("ix_ap_follower_app_domain", table_name="ap_followers")
op.drop_constraint("uq_follower_acct_app", "ap_followers", type_="unique")
op.create_unique_constraint(
"uq_follower_acct",
"ap_followers",
["actor_profile_id", "follower_acct"],
)
op.alter_column("ap_followers", "app_domain", nullable=True, server_default=None)
op.drop_column("ap_followers", "app_domain")

View File

@@ -0,0 +1,33 @@
"""Add app_domain to ap_delivery_log for per-domain idempotency
Revision ID: u1s9o5p7q8
Revises: t0r8n4o6p7
"""
from alembic import op
import sqlalchemy as sa
revision = "u1s9o5p7q8"
down_revision = "t0r8n4o6p7"
def upgrade() -> None:
op.add_column(
"ap_delivery_log",
sa.Column("app_domain", sa.String(128), nullable=False, server_default="federation"),
)
op.drop_constraint("uq_delivery_activity_inbox", "ap_delivery_log", type_="unique")
op.create_unique_constraint(
"uq_delivery_activity_inbox_domain",
"ap_delivery_log",
["activity_id", "inbox_url", "app_domain"],
)
def downgrade() -> None:
op.drop_constraint("uq_delivery_activity_inbox_domain", "ap_delivery_log", type_="unique")
op.drop_column("ap_delivery_log", "app_domain")
op.create_unique_constraint(
"uq_delivery_activity_inbox",
"ap_delivery_log",
["activity_id", "inbox_url"],
)

View File

@@ -1,9 +1,9 @@
# The monolith has been split into three apps (apps/coop, apps/market, apps/cart).
# The monolith has been split into three apps (apps/blog, apps/market, apps/cart).
# This package remains for shared infrastructure modules (middleware, redis_cacher,
# csrf, errors, authz, filters, utils, bp/*).
#
# To run individual apps:
# hypercorn apps.coop.app:app --bind 0.0.0.0:8000
# hypercorn apps.blog.app:app --bind 0.0.0.0:8000
# hypercorn apps.market.app:app --bind 0.0.0.0:8001
# hypercorn apps.cart.app:app --bind 0.0.0.0:8002
#

View File

@@ -1,44 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,49 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
{# Account header #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
{% if g.user %}
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
{% if g.user.name %}
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
{% endif %}
{% endif %}
</div>
<form action="{{ coop_url('/auth/logout/') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
>
<i class="fa-solid fa-right-from-bracket text-xs"></i>
Sign out
</button>
</form>
</div>
{# Labels #}
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
{% if labels %}
<div>
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
<div class="flex flex-wrap gap-2">
{% for label in labels %}
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
{{ label.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -1,17 +0,0 @@
{% import 'macros/links.html' as links %}
{% call links.link(coop_url('/auth/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% for link in account_nav_links %}
{% if link.external %}
<div class="relative nav-group">
<a href="{{ link.href_fn() }}" class="{{styles.nav_button}}" data-hx-disable>
{{ link.label }}
</a>
</div>
{% else %}
{% call links.link(link.href_fn(), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{{ link.label }}
{% endcall %}
{% endif %}
{% endfor %}

View File

@@ -1,17 +0,0 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ coop_url('/auth/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
></span>
</button>
</div>

View File

@@ -1,46 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
{% if newsletter_list %}
<div class="divide-y divide-stone-100">
{% for item in newsletter_list %}
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
{% if item.newsletter.description %}
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
{% endif %}
</div>
<div class="ml-4 flex-shrink-0">
{% if item.un %}
{% with un=item.un %}
{% include "_types/auth/_newsletter_toggle.html" %}
{% endwith %}
{% else %}
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ coop_url('/auth/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
role="switch"
aria-checked="false"
>
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No newsletters available.</p>
{% endif %}
</div>
</div>

View File

@@ -1,29 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -1,44 +0,0 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,33 +0,0 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
If an account exists for
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
youll receive a link to sign in. It expires in 15 minutes.
</p>
{% if email_error %}
<div
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
role="alert"
>
<span class="font-medium">Heads up:</span>
<span>{{ email_error }}</span>
</div>
{% endif %}
<p class="mt-6 text-sm">
<a
href="{{ coop_url('/auth/login/') }}"
class="text-stone-600 dark:text-stone-300 hover:underline"
>
← Back
</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(coop_url('/auth/account/'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -1,18 +0,0 @@
{% extends "_types/root/_index.html" %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include "_types/auth/_nav.html" %}
{% endblock %}
{% block content %}
{% include '_types/auth/_main_panel.html' %}
{% endblock %}

View File

@@ -1,18 +0,0 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -1,46 +0,0 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Enter your email and well email you a one-time sign-in link.
</p>
{% if error %}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
<form
method="post" action="{{ coop_url('/auth/start/') }}"
class="mt-6 space-y-5"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
autocomplete="email"
inputmode="email"
>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
>
Send link
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,51 +0,0 @@
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
<div class="flex flex-wrap gap-2 px-4 py-3">
{% if has_access('blog.new_post') %}
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
{% endif %}
{% if g.user and (draft_count or drafts) %}
{% if drafts %}
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a
href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% else %}
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a
href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% endif %}
{% endif %}
</div>

View File

@@ -1,79 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<article class="border-b pb-6 last:border-b-0 relative">
{# ❤️ like button - OUTSIDE the link, aligned with image top #}
{% if g.user %}
<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
<header class="mb-2 text-center">
<h2 class="text-4xl font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-2 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</header>
{% if post.feature_image %}
<div class="mb-4">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full object-cover"
>
</div>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.custom_excerpt }}
</p>
{% else %}
{% if post.excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.excerpt }}
</p>
{% endif %}
{% endif %}
</a>
{# Widget-driven card decorations #}
{% for w in widgets.container_cards %}
{% include w.template with context %}
{% endfor %}
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -1,19 +0,0 @@
<div class="flex flex-row justify-center gap-3">
{% if post.tags %}
<div class="mt-4 flex items-center gap-2">
<div>in</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/tags.html' %}
</ul>
</div>
{% endif %}
<div></div>
{% if post.authors %}
<div class="mt-4 flex items-center gap-2">
<div>by</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/authors.html' %}
</ul>
</div>
{% endif %}
</div>

View File

@@ -1,21 +0,0 @@
{% macro author(author) %}
{% if author %}
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6"></div>
{# optional fallback circle with first letter
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div> #}
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ author.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -1,32 +0,0 @@
{# --- AUTHORS LIST STARTS HERE --- #}
{% if post.authors and post.authors|length %}
{% for a in post.authors %}
{% for author in authors if author.slug==a.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_author': author.slug }|qs|host}}"
>
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- AUTHOR LIST ENDS HERE --- #}

View File

@@ -1,19 +0,0 @@
{% macro tag(tag) %}
{% if tag %}
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ tag.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -1,22 +0,0 @@
{% macro tag_group(group) %}
{% if group %}
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -1,17 +0,0 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# --- TAG LIST STARTS HERE --- #}
{% if post.tags and post.tags|length %}
{% for t in post.tags %}
{% for tag in tags if tag.slug==t.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_tag': tag.slug }|qs|host}}"
>
{{dotag.tag(tag)}}
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- TAG LIST ENDS HERE --- #}

View File

@@ -1,59 +0,0 @@
<article class="relative">
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
{% if post.feature_image %}
<div>
<img
src="{{ post.feature_image }}"
alt=""
class="w-full aspect-video object-cover"
>
</div>
{% endif %}
<div class="p-3 text-center">
<h2 class="text-lg font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-1 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.custom_excerpt }}
</p>
{% elif post.excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.excerpt }}
</p>
{% endif %}
</div>
</a>
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -1,111 +0,0 @@
{% for post in posts %}
{% if view == 'tile' %}
{% include "_types/blog/_card_tile.html" %}
{% else %}
{% include "_types/blog/_card.html" %}
{% endif %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -1,48 +0,0 @@
{# View toggle bar - desktop only #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('blog_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('blog_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if view == 'tile' %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/blog/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/blog/_cards.html" %}
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -1,40 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob_.html' import root_header with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'blog-header-child', '_types/blog/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{# Filter container - blog doesn't have child_summary but still needs this element #}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{# Aside with filters #}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/root/_nav.html' %}
{% include '_types/root/_nav_panel.html' %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -1,9 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -1,79 +0,0 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
{# --- Edit group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.save', id=group.id) }}"
class="border rounded p-4 bg-white space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Name</label>
<input
type="text" name="name" value="{{ group.name }}" required
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-stone-600 mb-1">Colour</label>
<input
type="text" name="colour" value="{{ group.colour or '' }}" placeholder="#hex"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="w-24">
<label class="block text-xs font-medium text-stone-600 mb-1">Order</label>
<input
type="number" name="sort_order" value="{{ group.sort_order }}"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Feature Image URL</label>
<input
type="text" name="feature_image" value="{{ group.feature_image or '' }}"
placeholder="https://..."
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
{# --- Tag checkboxes --- #}
<div>
<label class="block text-xs font-medium text-stone-600 mb-2">Assign Tags</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2">
{% for tag in all_tags %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer">
<input
type="checkbox" name="tag_ids" value="{{ tag.id }}"
{% if tag.id in assigned_tag_ids %}checked{% endif %}
class="rounded border-stone-300"
>
{% if tag.feature_image %}
<img src="{{ tag.feature_image }}" alt="" class="h-4 w-4 rounded-full object-cover">
{% endif %}
<span>{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-3">
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Save
</button>
</div>
</form>
{# --- Delete form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.delete_group', id=group.id) }}"
class="border-t pt-4"
onsubmit="return confirm('Delete this tag group? Tags will not be deleted.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="border rounded px-4 py-2 bg-red-600 text-white text-sm">
Delete Group
</button>
</form>
</div>

View File

@@ -1,17 +0,0 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('tag-groups-header-child', 'tag-groups-edit-child', '_types/blog/admin/tag_groups/_edit_header.html')}}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -1,9 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -1,73 +0,0 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
{# --- Create new group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.create') }}" class="border rounded p-4 bg-white space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h3 class="text-sm font-semibold text-stone-700">New Group</h3>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text" name="name" placeholder="Group name" required
class="flex-1 border rounded px-3 py-2 text-sm"
>
<input
type="text" name="colour" placeholder="#colour"
class="w-28 border rounded px-3 py-2 text-sm"
>
<input
type="number" name="sort_order" placeholder="Order" value="0"
class="w-20 border rounded px-3 py-2 text-sm"
>
</div>
<input
type="text" name="feature_image" placeholder="Image URL (optional)"
class="w-full border rounded px-3 py-2 text-sm"
>
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Create
</button>
</form>
{# --- Existing groups list --- #}
{% if groups %}
<ul class="space-y-2">
{% for group in groups %}
<li class="border rounded p-3 bg-white flex items-center gap-3">
{% if group.feature_image %}
<img src="{{ group.feature_image }}" alt="{{ group.name }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">
{% else %}
<div class="h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}">
{{ group.name[:1] }}
</div>
{% endif %}
<div class="flex-1">
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}"
class="font-medium text-stone-800 hover:underline">
{{ group.name }}
</a>
<span class="text-xs text-stone-500 ml-2">{{ group.slug }}</span>
</div>
<span class="text-xs text-stone-500">order: {{ group.sort_order }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-stone-500 text-sm">No tag groups yet.</p>
{% endif %}
{# --- Unassigned tags --- #}
{% if unassigned_tags %}
<div class="border-t pt-4">
<h3 class="text-sm font-semibold text-stone-700 mb-2">Unassigned Tags ({{ unassigned_tags|length }})</h3>
<div class="flex flex-wrap gap-2">
{% for tag in unassigned_tags %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded">
{{ tag.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -1,16 +0,0 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends '_types/blog/admin/tag_groups/index.html' %}
{% block tag_groups_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_edit_header.html' import header_row with context %}
{{ header_row() }}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -1,20 +0,0 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_header.html' import header_row with context %}
{{ header_row() }}
<div id="tag-groups-header-child">
{% block tag_groups_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

View File

@@ -1,19 +0,0 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% include '_types/blog/_action_buttons.html' %}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
</div>

View File

@@ -1,62 +0,0 @@
{% import '_types/blog/_card/author.html' as doauthor %}
{# Author filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_authors | length == 0) %}
{% set href =
{
'remove_author': selected_authors,
}|qs
|host %}
<a
class="px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any author
</a>
</li>
{% for author in authors %}
<li>
{% set is_on = (selected_authors and (author.slug in selected_authors)) %}
{% set qs = {"remove_author": author.slug, "page":None}|qs if is_on
else {"add_author": author.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{doauthor.author(author)}}
{% if False and author.bio %}
<span class="inline-block flex-1 bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{% if author.bio|length > 50 %}
{{ author.bio[:50] ~ "…" }}
{% else %}
{{ author.bio }}
{% endif %}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,70 +0,0 @@
{# Tag group filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_groups | length == 0 and selected_tags | length == 0) %}
{% set href =
{
'remove_group': selected_groups,
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Topic
</a>
</li>
{% for group in tag_groups %}
{% if group.post_count > 0 or (selected_groups and group.slug in selected_groups) %}
<li>
{% set is_on = (selected_groups and (group.slug in selected_groups)) %}
{% set qs = {"remove_group": group.slug, "page":None}|qs if is_on
else {"add_group": group.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
<span class="flex-1"></span>
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.post_count }}
</span>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>

View File

@@ -1,59 +0,0 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# Tag filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_tags | length == 0) %}
{% set href =
{
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Tag
</a>
</li>
{% for tag in tags %}
<li>
{% set is_on = (selected_tags and (tag.slug in selected_tags)) %}
{% set qs = {"remove_tag": tag.slug, "page":None}|qs if is_on
else {"add_tag": tag.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{dotag.tag(tag)}}
{% if False and tag.description %}
<span class="flex-1 inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.description }}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,7 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='blog-row', oob=oob) %}
<div></div>
{% endcall %}
{% endmacro %}

View File

@@ -1,37 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block meta %}
{{ super() }}
<script>
(function() {
var p = new URLSearchParams(window.location.search);
if (!p.has('view')
&& window.matchMedia('(min-width: 768px)').matches
&& localStorage.getItem('blog_view') === 'tile') {
p.set('view', 'tile');
window.location.replace(window.location.pathname + '?' + p.toString());
}
})();
</script>
{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% block root_blog_header %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -1,13 +0,0 @@
<div class="md:hidden mx-2 bg-stone-200 rounded">
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
<i class="fa-solid fa-filter"></i>
</span>
<span>
<svg aria-hidden="true" viewBox="0 0 24 24"
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
<path d="M6 9l6 6 6-6" fill="currentColor"/>
</svg>
</span>
</div>

View File

@@ -1,14 +0,0 @@
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
{% include '_types/blog/mobile/_filter/summary/tag_groups.html' %}
{% include '_types/blog/mobile/_filter/summary/authors.html' %}
{% endcall %}
{% include '_types/blog/_action_buttons.html' %}
<div id="filter-details-mobile" style="display:contents">
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
{% endcall %}

View File

@@ -1,31 +0,0 @@
{% if selected_authors and selected_authors|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_authors %}
{% for author in authors %}
{% if st == author.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
<span>
{{author.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -1,33 +0,0 @@
{% if selected_groups and selected_groups|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for sg in selected_groups %}
{% for group in tag_groups %}
{% if sg == group.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.name }}
</span>
<span>
{{group.post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -1,31 +0,0 @@
{% if selected_tags and selected_tags|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_tags %}
{% for tag in tags %}
{% if st == tag.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.name }}
</span>
<span>
{{tag.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -1,22 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8 text-center">
<div class="text-6xl mb-4">📝</div>
<h1 class="text-2xl font-bold text-stone-800 mb-2">Post Not Found</h1>
<p class="text-stone-600 mb-6">
The post "{{ slug }}" could not be found.
</p>
<a
href="{{ url_for('blog.home')|host }}"
hx-get="{{ url_for('blog.home')|host }}"
hx-target="#main-panel"
hx-select="{{ hx_select }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
>
← Back to Blog
</a>
</div>
{% endblock %}

View File

@@ -1,55 +0,0 @@
<div class="p-4 space-y-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
</div>
{% if drafts %}
<div class="space-y-3">
{% for draft in drafts %}
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
<a
href="{{ edit_href }}"
hx-boost="false"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-stone-900 truncate">
{{ draft.title or "Untitled" }}
</h3>
{% if draft.excerpt %}
<p class="text-stone-600 text-sm mt-1 line-clamp-2">
{{ draft.excerpt }}
</p>
{% endif %}
{% if draft.updated_at %}
<p class="text-xs text-stone-400 mt-2">
Updated: {{ draft.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 flex-shrink-0">
Draft
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500 text-center py-8">No drafts yet.</p>
{% endif %}
</div>

View File

@@ -1,12 +0,0 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -1,259 +0,0 @@
{# ── Error banner ── #}
{% if save_error %}
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
<strong>Save failed:</strong> {{ save_error }}
</div>
{% endif %}
<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" id="lexical-json-input" name="lexical" value="">
<input type="hidden" id="feature-image-input" name="feature_image" value="">
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">
{# ── Feature image ── #}
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
{# Empty state: add link #}
<div id="feature-image-empty">
<button
type="button"
id="feature-image-add-btn"
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
>+ Add feature image</button>
</div>
{# Filled state: image preview + controls #}
<div id="feature-image-filled" class="relative hidden">
<img
id="feature-image-preview"
src=""
alt=""
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
>
{# Delete button (top-right, visible on hover) #}
<button
type="button"
id="feature-image-delete-btn"
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
flex items-center justify-center opacity-0 group-hover:opacity-100
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
title="Remove feature image"
><i class="fa-solid fa-trash-can"></i></button>
{# Caption input #}
<input
type="text"
id="feature-image-caption"
value=""
placeholder="Add a caption..."
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
outline-none placeholder:text-stone-300 focus:text-stone-700"
>
</div>
{# Upload spinner overlay #}
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
</div>
{# Hidden file input #}
<input
type="file"
id="feature-image-file"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
class="hidden"
>
</div>
{# ── Title ── #}
<input
type="text"
name="title"
value=""
placeholder="{{ 'Page title...' if is_page else 'Post title...' }}"
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
placeholder:text-stone-300 mb-[8px] leading-tight"
>
{# ── Excerpt ── #}
<textarea
name="custom_excerpt"
rows="1"
placeholder="Add an excerpt..."
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
></textarea>
{# ── Editor mount point ── #}
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
{# ── Status + Save footer ── #}
<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
<select
name="status"
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
>
<option value="draft" selected>Draft</option>
<option value="published">Published</option>
</select>
<button
type="submit"
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
hover:bg-stone-800 transition-colors cursor-pointer"
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
</div>
</form>
{# ── Koenig editor assets ── #}
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
<style>
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
We apply that via JS (see init() below) so the header bars render at
normal size on first paint. A beforeSwap listener restores the
default when navigating away. */
#lexical-editor { display: flow-root; }
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
#lexical-editor [data-kg-card="html"] * { float: none !important; }
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
</style>
<script src="{{ asset_url('scripts/editor.js') }}"></script>
<script>
(function() {
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
restore default when navigating away via HTMX ── */
function applyEditorFontSize() {
document.documentElement.style.fontSize = '62.5%';
document.body.style.fontSize = '1.6rem';
}
function restoreDefaultFontSize() {
document.documentElement.style.fontSize = '';
document.body.style.fontSize = '';
}
applyEditorFontSize();
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
if (e.detail.target && e.detail.target.id === 'main-panel') {
restoreDefaultFontSize();
document.body.removeEventListener('htmx:beforeSwap', cleanup);
}
});
function init() {
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
var uploadUrls = {
image: uploadUrl,
media: '{{ url_for("blog.editor_api.upload_media") }}',
file: '{{ url_for("blog.editor_api.upload_file") }}',
};
/* ── Feature image upload / delete / replace ── */
var fileInput = document.getElementById('feature-image-file');
var addBtn = document.getElementById('feature-image-add-btn');
var deleteBtn = document.getElementById('feature-image-delete-btn');
var preview = document.getElementById('feature-image-preview');
var emptyState = document.getElementById('feature-image-empty');
var filledState = document.getElementById('feature-image-filled');
var hiddenUrl = document.getElementById('feature-image-input');
var hiddenCaption = document.getElementById('feature-image-caption-input');
var captionInput = document.getElementById('feature-image-caption');
var uploading = document.getElementById('feature-image-uploading');
function showFilled(url) {
preview.src = url;
hiddenUrl.value = url;
emptyState.classList.add('hidden');
filledState.classList.remove('hidden');
uploading.classList.add('hidden');
}
function showEmpty() {
preview.src = '';
hiddenUrl.value = '';
hiddenCaption.value = '';
captionInput.value = '';
emptyState.classList.remove('hidden');
filledState.classList.add('hidden');
uploading.classList.add('hidden');
}
function uploadFile(file) {
emptyState.classList.add('hidden');
uploading.classList.remove('hidden');
var fd = new FormData();
fd.append('file', file);
fetch(uploadUrl, {
method: 'POST',
body: fd,
headers: { 'X-CSRFToken': csrfToken },
})
.then(function(r) {
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
return r.json();
})
.then(function(data) {
var url = data.images && data.images[0] && data.images[0].url;
if (url) showFilled(url);
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
})
.catch(function(e) {
showEmpty();
alert(e.message);
});
}
addBtn.addEventListener('click', function() { fileInput.click(); });
preview.addEventListener('click', function() { fileInput.click(); });
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
showEmpty();
});
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) {
uploadFile(fileInput.files[0]);
fileInput.value = '';
}
});
captionInput.addEventListener('input', function() {
hiddenCaption.value = captionInput.value;
});
/* ── Auto-resize excerpt textarea ── */
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
function autoResize() {
excerpt.style.height = 'auto';
excerpt.style.height = excerpt.scrollHeight + 'px';
}
excerpt.addEventListener('input', autoResize);
autoResize();
/* ── Mount Koenig editor ── */
window.mountEditor('lexical-editor', {
initialJson: null,
csrfToken: csrfToken,
uploadUrls: uploadUrls,
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
unsplashApiKey: '{{ unsplash_api_key or "" }}',
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
});
/* ── Ctrl-S / Cmd-S to save ── */
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
document.getElementById('post-new-form').requestSubmit();
}
});
}
/* editor.js loads synchronously on full page loads but asynchronously
when HTMX swaps the content in, so wait for it if needed. */
if (typeof window.mountEditor === 'function') {
init();
} else {
var _t = setInterval(function() {
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
}, 50);
}
})();
</script>

View File

@@ -1,12 +0,0 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -1,7 +0,0 @@
{% import "macros/links.html" as links %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for('market.browse.product.admin', slug=slug)
)}}
{% endif %}

View File

@@ -1,5 +0,0 @@
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">
{% include "_types/browse/_product_cards.html" %}
</div>
<div class="pb-8"></div>

View File

@@ -1,37 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'market-header-child', '_types/market/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/market/mobile/_nav_panel.html' %}
{% endblock %}
{# Filter container with child summary - from browse/index.html child_summary block #}
{% block filter %}
{% include "_types/browse/mobile/_filter/summary.html" %}
{% endblock %}
{% block aside %}
{% include "_types/browse/desktop/menu.html" %}
{% endblock %}
{% block content %}
{% include "_types/browse/_main_panel.html" %}
{% endblock %}

View File

@@ -1,104 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(p, prices_ns) }}
{% set item_href = url_for('market.browse.product.product_detail', slug=p.slug)|host %}
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
{# ❤️ like button overlay - OUTSIDE the link #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">
{% set slug = p.slug %}
{% set liked = p.is_liked or False %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class=""
>
{# Make this relative so we can absolutely position children #}
<div class="w-full aspect-square bg-stone-100 relative">
{% if p.image %}
<figure class="inline-block w-full h-full">
<div class="relative w-full h-full">
<img
src="{{ p.image }}"
alt="no image"
class="absolute inset-0 w-full h-full object-contain object-top"
loading="lazy" decoding="async" fetchpriority="low"
/>
{% for l in p.labels %}
<img
src="{{ asset_url('labels/' + l + '.svg') }}"
alt=""
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
/>
{% endfor %}
</div>
<figcaption class="
mt-2 text-sm text-center
{{ 'bg-yellow-200' if p.brand in selected_brands else '' }}
text-stone-600
">
{{ p.brand }}
</figcaption>
</figure>
{% else %}
<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">
<div class="text-stone-400 text-xs">No image</div>
<ul class="flex flex-row gap-1">
{% for l in p.labels %}
<li>{{ l }}</li>
{% endfor %}
</ul>
<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.brand }}
</div>
</div>
{% endif %}
</div>
{# <div>{{ prices.rrp(prices_ns) }}</div> #}
{{ prices.card_price(p)}}
{% import '_types/product/_cart.html' as _cart %}
</a>
<div class="flex justify-center">
{{ _cart.add(p.slug, cart)}}
</div>
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div class="flex flex-row justify-center gap-2 p-2">
{% for s in p.stickers %}
{{ stick.sticker(
asset_url('stickers/' + s + '.svg'),
s,
True,
size=24,
found=s in selected_stickers
) }}
{% endfor %}
</div>
<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.title | highlight(search) }}
</div>
</a>
</div>

View File

@@ -1,107 +0,0 @@
{% for p in products %}
{% include "_types/browse/_product_card.html" %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -1,40 +0,0 @@
{# Categories #}
<nav aria-label="Categories"
class="rounded-xl border bg-white shadow-sm min-h-0">
<ul class="divide-y">
{% set top_active = (sub_slug is not defined or sub_slug is none or sub_slug == '') %}
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if top_active else 'false' }}"
class="block px-4 py-3 text-[15px] transition {{select_colours}}">
<div class="prose prose-stone max-w-none">All products</div>
</a>
</li>
{% for sub in subs_local %}
{% set active = (sub.slug == sub_slug) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if active else 'false' }}"
class="block px-4 py-3 text-[15px] border-l-4 transition {{select_colours}}"
>
<div class="prose prose-stone max-w-none">{{ (sub.html_label or sub.name) | safe }}</div>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,40 +0,0 @@
{# Brand filter (desktop, single-select) #}
{# Brands #}
<nav aria-label="Brands"
class="rounded-xl border bg-white shadow-sm">
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="divide-y">
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
{% if is_selected %}
{% set brand_href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set brand_href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{% endif %}
<li>
<a
href="{{ brand_href }}"
hx-get="{{ brand_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML" hx-push-url="true" hx-on:htmx:afterSwap="this.closest('details')?.removeAttribute('open')"
class="flex items-center gap-2 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="{% if b.count==0 %}text-lg text-red-500{% else %}text-sm{% endif %} {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,44 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="labels-details-desktop"
class="flex justify-center p-0 m-0 gap-2"
>
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page":None}|qs if is_on
else {"add_label": s.name, "page":None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -1,38 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% set qs = {"liked": None if liked else True, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-300 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>

View File

@@ -1,44 +0,0 @@
{% macro search(current_local_href,search, search_count, hx_select) -%}
<!-- Search (1/3 width → 4/12 columns) -->
<!-- nb this does NOT oob itself!! -->
<div
id="search-desktop-wrapper"
class="flex flex-row gap-2 items-center"
>
<input
id="search-desktop"
type="text"
name="search"
aria-label="search"
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-desktop"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
{{zap_filter}}
</div>
</div>
{% endmacro %}

View File

@@ -1,34 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% set sort_val = sort|default('az', true) %}
<ul
id="sort-details-desktop"
class="flex w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-0 [&>li]:list-none [&>li]:flex-1"
>
{% for key,label,icon in sort_options %}
{% set is_on = (sort_val == key) %}
{% set qs = {"sort": None, "page": None}|qs if is_on
else {"sort": key, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
class="flex flex-col items-center justify-center w-full h-full py-2 m-0"
>
{{ stick.sticker(asset_url(icon), label, is_on) }}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -1,46 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="stickers-details-desktop"
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
>
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host%}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<span class="text-[11px]">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -1,37 +0,0 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
<div class="mb-4">
<div class="text-2xl uppercase tracking-wide text-black-500">{{ category_label }}</div>
</div>
{% include "_types/browse/desktop/_filter/sort.html" %}
<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">
{% include "_types/browse/desktop/_filter/like.html" %}
{% if labels %}
{% include "_types/browse/desktop/_filter/labels.html" %}
{% endif %}
</nav>
{% if stickers %}
{% include "_types/browse/desktop/_filter/stickers.html" %}
{% endif %}
{% if subs_local and top_local_href %}
{% include "_types/browse/desktop/_category_selector.html" %}
{% endif %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include "_types/browse/desktop/_filter/brand.html" %}
</div>

View File

@@ -1,13 +0,0 @@
{% extends '_types/market/index.html' %}
{% block filter %}
{% include "_types/browse/mobile/_filter/summary.html" %}
{% endblock %}
{% block aside %}
{% include "_types/browse/desktop/menu.html" %}
{% endblock %}
{% block content %}
{% include "_types/browse/_main_panel.html" %}
{% endblock %}

View File

@@ -1,20 +0,0 @@
<button
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', slug=slug)|host }}"
hx-target="this"
hx-swap="outerHTML"
hx-push-url="false"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-swap-settle="0ms"
{% if liked %}
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
{% else %}
aria-label="Like this {{ item_type if item_type else 'product' }}"
{% endif %}
>
{% if liked %}
<i aria-hidden="true" class="fa-solid fa-heart"></i>
{% else %}
<i aria-hidden="true" class="fa-regular fa-heart"></i>
{% endif %}
</button>

View File

@@ -1,40 +0,0 @@
<nav aria-label="Brands" class="px-4 pb-3" >
{% if brands|length %}
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="space-y-1 pr-1" >
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
<li>
{{current_local_href}}
<a
{% if is_selected %}
{% set href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{%endif%}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="flex items-center gap-2 my-3 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="text-xs {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</nav>

View File

@@ -1,30 +0,0 @@
{% include "_types/browse/mobile/_filter/sort_ul.html" %}
{% if search or selected_labels|length or selected_stickers|length or selected_brands|length %}
{% set href = (current_local_href ~ {"clear_filters": True}|qs)|host %}
<div class = "flex flex-row justify-center">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
title="clear filters"
aria-label="clear filters"
class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">
<span class="mt-1 leading-none tabular-nums"
>
clear filters
</span>
</a>
</div>
{% endif %}
<div class="flex flex-row gap-2 justify-center items center">
{% include "_types/browse/mobile/_filter/like.html" %}
{% include "_types/browse/mobile/_filter/labels.html" %}
</div>
{% include "_types/browse/mobile/_filter/stickers.html" %}
{% include "_types/browse/mobile/_filter/brand_ul.html" %}

View File

@@ -1,47 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="labels" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on
else {"add_label": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="list-none shrink-0">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -1,40 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="like" class="px-4 pb-3">
{% set qs = {"liked": None if liked else True, "page": None}|qs%}
{% set href = (current_local_href ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-500 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>
</nav>

View File

@@ -1,40 +0,0 @@
{% macro search(current_local_href, search, search_count, hx_select) -%}
<div
id="search-mobile-wrapper"
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
>
<input
id="search-mobile"
type="text"
name="search"
aria-label="search"
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-mobile"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -1,33 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="sort" class="px-4 pb-3" >
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
{% for key,label,icon in sort_options %}
<li class="list-none">
<div class="flex flex-col items-center justify-center w-full">
<a
{% if sort == key %}
{% set href= (current_local_href, {"sort": None, "page": None}|qs )|host %}
{% else %}
{% set href= (current_local_href ~ {"sort": key, "page": None}|qs )|host %}
{% endif %}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{ stick.sticker(asset_url(icon), label, sort==key) }}
</a>
</div>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,50 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="stickers" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="list-none shrink-0">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
<span class="text-sm">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on) }}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -1,120 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
<div
class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2"
role="list">
<div class="flex flex-row items-start gap-2">
{% if sort %}
<ul class="relative inline-flex items-center justify-center gap-2">
<!-- sticker icon -->
{% for k,l,i in sort_options %}
{% if k == sort %}
{% set key = k %}
{% set label = l %}
{% set icon = i %}
<li role="listitem">
{{ stick.sticker(asset_url(icon), label, True)}}
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% if liked %}
<div class="flex flex-col items-center gap-1 pb-1">
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% if liked_count is not none %}
<div class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums"
>
{{ liked_count }}
</div>
{% endif %}
</div>
{% endif %}
{% if selected_labels and selected_labels|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_labels %}
{% for s in labels %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<div class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</div>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{% if selected_stickers and selected_stickers|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_stickers %}
{% for s in stickers %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
<!-- sticker icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
</div>
{% if selected_brands and selected_brands|length %}
<ul class_="w-full grid grid-cols-12 items-center gap-3 px-4 py-3">
{% for b in selected_brands %}
<li role="listitem" class="flex flex-row items-center gap-2">
{% set ns = namespace(count=0) %}
{% for brand in brands %}
{% if brand.name == b %}
{% set ns.count = brand.count %}
{% endif %}
{% endfor %}
{% if ns.count %}
<div class="text-md">{{ b }}</div>
<div class="text-md">{{ ns.count }}</div>
{% else %}
<div class="text-md text-red-500">{{ b }}</div>
<div class="text-xl text-red-500">0</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endcall %}
<div id="filter-details-mobile" style="display:contents">
{% include "_types/browse/mobile/_filter/index.html" %}
</div>
{% endcall %}

View File

@@ -1,12 +0,0 @@
{% macro description(calendar, oob=False) %}
<div
id="calendar-description-title"
{% if oob %}
hx-swap-oob="outerHTML"
{% endif %}
class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
>
{{ calendar.description or ''}}
</div>
{% endmacro %}

View File

@@ -1,180 +0,0 @@
<section class="bg-orange-100">
<header class="flex items-center justify-center mt-2">
{# Month / year navigation #}
<nav class="flex items-center gap-2 text-2xl">
{# Outer left: -1 year #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-get="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
>
&laquo;
</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"
>
&lsaquo;
</a>
<div class="px-3 font-medium">
{{ month_name }} {{ year }}
</div>
{# Inner right: +1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('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"
>
&rsaquo;
</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"
>
&raquo;
</a>
</nav>
</header>
{# Calendar grid #}
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">
{# Weekday header: only show on sm+ (desktop/tablet) #}
<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">
{% for wd in weekday_names %}
<div class="py-1">{{ wd }}</div>
{% endfor %}
</div>
{# On mobile: 1 column; on sm+: 7 columns #}
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">
{% for week in weeks %}
{% for day in week %}
<div
class="min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %} {% if day.is_today %} ring-2 ring-blue-500 z-10 relative {% endif %}"
>
<div class="flex justify-between items-center">
<div class="flex flex-col">
<span class="sm:hidden text-[16px] text-stone-500">
{{ day.date.strftime('%a') }}
</span>
{# Clickable day number: goes to day detail view #}
<a
class="{{styles.pill}}"
href="{{ url_for('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>

View File

@@ -1,12 +0,0 @@
<!-- Desktop nav -->
{% import 'macros/links.html' as links %}
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/slots/') }}" class="{{styles.nav_button}}">
<i class="fa fa-clock" aria-hidden="true"></i>
<div>Slots</div>
</a>
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/admin/') }}" class="{{styles.nav_button}}">
<i class="fa fa-cog" aria-hidden="true"></i>
</a>
{% endif %}

View File

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

View File

@@ -1,33 +0,0 @@
<div id="calendar-description">
{% if calendar.description %}
<p class="text-stone-700 whitespace-pre-line break-all">
{{ calendar.description }}
</p>
{% else %}
<p class="text-stone-400 italic">
No description yet.
</p>
{% endif %}
<button
type="button"
class="mt-2 text-xs underline"
hx-get="{{ url_for(
'calendars.calendar.admin.calendar_description_edit',
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"
hx-swap="outerHTML"
>
<i class="fas fa-edit"></i>
</button>
</div>
{% if oob %}
{% from '_types/calendar/_description.html' import description %}
{{description(calendar, oob=True)}}
{% endif %}

View File

@@ -1,43 +0,0 @@
<div id="calendar-description">
<form
hx-post="{{ url_for(
'calendars.calendar.admin.calendar_description_save',
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea
name="description"
autocomplete="off"
rows="4"
class="w-full p-2 border rounded"
>{{ calendar.description or '' }}</textarea>
<div class="mt-2 flex gap-2 text-xs">
<button
type="submit"
class="px-3 py-1 rounded bg-stone-800 text-white"
>
Save
</button>
<button
type="button"
class="px-3 py-1 rounded border"
hx-get="{{ url_for(
'calendars.calendar.admin.calendar_description_view',
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"
hx-swap="outerHTML"
>
Cancel
</button>
</div>
</form>
</div>

View File

@@ -1,46 +0,0 @@
<section class="max-w-3xl mx-auto p-4 space-y-10">
<!-- Calendar config -->
<div>
<h2 class="text-xl font-semibold">Calendar configuration</h2>
<div id="cal-put-errors" class="mt-2 text-sm text-red-600"></div>
<div>
<label class="block text-sm font-medium text-stone-700">
Description
</label>
{% include '_types/calendar/admin/_description.html' %}
</div>
<form
id="calendar-form"
method="post"
hx-put="{{ url_for('calendars.calendar.put', slug=post.slug, calendar_slug=calendar.slug ) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"
hx-on::response-error="document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;"
hx-on::after-request="if (event.detail.successful) this.reset()"
class="hidden space-y-4 mt-4"
autocomplete="off"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-sm font-medium text-stone-700">Description</label>
<div>{{calendar.description or ''}}</div>
<textarea
name="description"
autocomplete="off"
rows="4" class="w-full p-2 border rounded"
>{{ (calendar.description or '') }}</textarea>
</div>
<div>
<button class="px-3 py-2 rounded bg-stone-800 text-white">Save</button>
</div>
</form>
</div>
<hr class="border-stone-200">
</section>

View File

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

View File

@@ -1,25 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for calendar admin page #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('calendar-header-child', 'calendar-admin-header-child', '_types/calendar/admin/header/_header.html')}}
{% from '_types/calendar/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% include '_types/calendar/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/calendar/admin/_main_panel.html' %}
{% endblock %}

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendar-row', oob=oob) %}
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') }}" class="flex items-center gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
<div class="flex flex-row items-center gap-2">
<i class="fa fa-calendar"></i>
<div class="shrink-0">
{{ calendar.name }}
</div>
</div>
{% from '_types/calendar/_description.html' import description %}
{{description(calendar)}}
</div>
</a>
{% call links.desktop_nav() %}
{% include '_types/calendar/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

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

View File

@@ -1,44 +0,0 @@
{% for row in calendars %}
{% set cal = row %}
<div class="mt-6 border rounded-lg p-4">
<div class="flex items-center justify-between gap-3">
{% set calendar_href = url_for('calendars.calendar.get', slug=post.slug, calendar_slug=cal.slug)|host%}
<a
class="flex items-baseline gap-3"
href="{{ calendar_href }}"
hx-get="{{ calendar_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<h3 class="font-semibold">{{ cal.name }}</h3>
<h4 class="text-gray-500">/{{ cal.slug }}/</h4>
</a>
<!-- Soft delete -->
<button
class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
data-confirm
data-confirm-title="Delete calendar?"
data-confirm-text="Entries will be hidden (soft delete)"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('calendars.calendar.delete', slug=post.slug, calendar_slug=cal.slug) }}"
hx-trigger="confirmed"
hx-target="#calendars-list"
hx-select="#calendars-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken":"{{ csrf_token() }}"}'
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
{% else %}
<p class="text-gray-500 mt-4">No calendars yet. Create one above.</p>
{% endfor %}

View File

@@ -1,27 +0,0 @@
<section class="p-4">
{% if has_access('calendars.create_calendar') %}
<!-- error container under the inputs -->
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="mt-4 flex gap-2 items-end"
hx-post="{{ url_for('calendars.create_calendar', slug=post.slug) }}"
hx-target="#calendars-list"
hx-select="#calendars-list"
hx-swap="outerHTML"
hx-on::before-request="document.querySelector('#cal-create-errors').textContent='';"
hx-on::response-error="document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="flex-1">
<label class="block text-sm text-gray-600">Name</label>
<input name="name" type="text" required class="w-full border rounded px-3 py-2" placeholder="e.g. Events, Gigs, Meetings" />
</div>
<button type="submit" class="border rounded px-3 py-2">Add calendar</button>
</form>
{% endif %}
<!-- list -->
<div id="calendars-list" class="mt-6">
{% include "_types/calendars/_calendars_list.html" %}
</div>
</section>

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendars-row', oob=oob) %}
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>Calendars</div>
</a>
{% call links.desktop_nav() %}
{% include '_types/calendars/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

Some files were not shown because too many files have changed in this diff Show More