Compare commits

152 Commits

Author SHA1 Message Date
giles
57ae97f17b Fix circular fragment fetching (shared submodule update)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:20:50 +00:00
giles
08c58d34f9 Sync shared: fragment failures now raise by default
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:04:27 +00:00
giles
89d7767a59 trigger rebuild
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
2026-02-24 18:01:54 +00:00
giles
06dea73557 Remove cross-domain template copies, use shared macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
- Desktop/mobile search: use macros/search.html instead of browse copies
- Cart mini: removed (shared _oob.html now uses fragment slot)
- Browse like button: removed (no longer referenced)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:33:08 +00:00
giles
5b63d9fb93 Add cross-domain template copies for shared macro dependencies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
- browse/desktop+mobile filter search (market domain, used by shared layout.html filter_summary)
- cart/_mini.html (cart domain, used by shared _oob.html for OOB header swaps)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:17:35 +00:00
giles
bcbbc20c52 Sync shared submodule (bound DB connection pool)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:08:13 +00:00
giles
3c517fd4ca Own blog domain templates, remove fragment fallbacks (Phase 6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Blog, post, home, snippets, menu_items, settings templates moved
from shared to blog/templates/. Header fallbacks for cart-mini,
nav-tree, auth-menu removed (fragments only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:55:37 +00:00
giles
2bdde5cdbf Sync shared submodule (Phase 5 widget cleanup)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:59:10 +00:00
giles
1f697b2961 Phase 4: replace container widgets with fragment fetches
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Blog post page and home route fetch container-nav from events + market
concurrently via fetch_fragments(). Blog listing fetches container-cards
from events and parses per-post HTML via comment markers.
widget_paginate proxies calendar pagination to events fragment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:33:18 +00:00
giles
e5b02f1c44 Restore menu_items fallback, fix app slug URLs in nav fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
Keep get_navigation_tree() as fallback when nav-tree fragment fetch
fails. Also map all app slugs (market, events, federation, account)
to their proper cross-app URLs in the nav fragment template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:49:20 +00:00
giles
288b3caf7f Add nav-tree fragment: blog renders nav for all apps
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Blog now provides a nav-tree fragment at /internal/fragments/nav-tree
that accepts app_name and path params for correct aria-selected
highlighting. Blog itself consumes this fragment alongside cart-mini
and auth-menu in a single concurrent fetch_fragments() call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:35:02 +00:00
giles
3e11bef978 Update shared submodule (product_slug rename in templates)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:30:12 +00:00
giles
b12c8788c7 Fix duplicate auth menu: inject fragment in both desktop and mobile sections
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Fragment contains responsive desktop/mobile spans. Inject in both
header sections so each span shows at the correct breakpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:51:54 +00:00
giles
e9a59e5f93 Add cart + auth fragment pre-fetching to blog context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Phase 2 of fragment composition: blog_context() now concurrently
fetches cart-mini and auth-menu HTML fragments from cart and account
apps via fetch_fragments(). Updates shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:11:50 +00:00
giles
5fc758d3c1 Add fragment blueprint + sync shared: micro-frontend infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:46 +00:00
giles
e243d858fd Sync shared: instant logout detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:33 +00:00
giles
ec1bab869c Sync shared submodule: external delivery handler
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:41:17 +00:00
giles
f2685771c5 Sync shared: add artdag_url() helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:46 +00:00
giles
c4dee48d17 Sync shared: per-domain delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-02-23 21:54:15 +00:00
giles
49e7739853 Bold post titles in federated AP content
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
2026-02-23 21:40:04 +00:00
giles
3d18f3b61f Update shared: backfill only current posts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:36:50 +00:00
giles
525ed3d9a3 Update shared: debug Accept logging
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:16:03 +00:00
giles
9ab9350271 Update shared: rewrite object URLs for per-app AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:06 +00:00
giles
2679b5fb6c Update shared: fix activity ID domain mismatch in AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:13 +00:00
giles
69ab9ad0d9 Update shared submodule: exempt AP paths from auth redirect
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:29:08 +00:00
giles
47ebaa0eec Update shared submodule: AP delivery fixes + sentinel
Some checks are pending
Build and Deploy / build-and-deploy (push) Waiting to run
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:31:29 +00:00
giles
fd24ab5030 Update shared submodule: per-app AP actors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:22 +00:00
giles
4cc00c763c Home page: render content only, no title bar or post header
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:34:33 +00:00
giles
b96800c71a Fix home route: build full post context for template rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m52s
The post template needs context (base_title, container_nav_widgets,
page_cart_count) that the post blueprint's context_processor provides.
Since home() runs on the blog blueprint, it must build this context
itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:25:15 +00:00
giles
5f97c7cf46 Move blog index to /index, homepage renders Ghost "home" page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
/ now renders the Ghost page with slug "home" (site homepage).
/index serves the existing blog listing (posts, pages, filters).
All blog.home references updated to blog.index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:54:09 +00:00
giles
ff5ce235a4 Retrigger CI (Docker Hub image now cached)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m3s
2026-02-23 16:39:55 +00:00
giles
c1c2129772 Update shared submodule (at-least-once + delivery log)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
2026-02-23 16:21:11 +00:00
giles
5d824902ba Update shared submodule (NOTIFY/LISTEN event processor)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-02-23 16:05:16 +00:00
giles
6ae56daf04 Update shared submodule (add device_id migration)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-02-23 15:26:49 +00:00
giles
10c1873358 Update shared: blog_did = account_did, one device identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:12:24 +00:00
giles
957e3c3fd3 Update shared: device-id SSO with account_did + Redis login signal
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:01:49 +00:00
giles
8c084a8470 Sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:41:29 +00:00
giles
7fe2486631 Update shared: add aiohttp dependency
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:05:46 +00:00
giles
971a60ac63 Update shared: device cookie auth state detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:57:13 +00:00
giles
416650e642 Update shared: grant-based session revocation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:20 +00:00
giles
f93bc6f987 Iframe-based SSO logout (tolerates dead apps)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:21:41 +00:00
giles
fe8e477781 Update shared: remove sso_hint, add sso-clear logout chain
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:17:35 +00:00
giles
835f406546 Update shared: SSO revocation clears local session on logout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:16:01 +00:00
giles
20e931a934 Update shared submodule: account is now OAuth server
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m35s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:01:33 +00:00
giles
1a3bd45dce Add /auth/clear to reset stale cookies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:45:25 +00:00
giles
17cedb4ade Logout through federation sso-logout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:32:08 +00:00
giles
bde64bcc20 Silent SSO via sso_hint cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:24:52 +00:00
giles
925f9a9df2 Fix logout redirect to blog home
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:15:29 +00:00
giles
e6d78c1031 Fix logout to use local /auth/logout/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:41 +00:00
giles
f5e7e29c3b Sign-in → account, clear old shared cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:57:06 +00:00
giles
7bade78dc6 Trigger rebuild: per-app cookies + OAuth SSO
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:45:17 +00:00
giles
527003b183 Fix OAuth authorize URL prefix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:25:56 +00:00
giles
2fb2357caf Update shared submodule: OAuth SSO + account app support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:59:07 +00:00
giles
fb1cef6cb5 Remove blog auth blueprint — login/account now in federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
All auth routes (login, magic link, account, newsletters,
widget pages, logout) are handled by the federation app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:09:21 +00:00
giles
85fd9d9f60 Update shared submodule (fix root top-bar account link)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:07:50 +00:00
giles
989610b533 Update shared submodule (account URLs → federation)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:01:15 +00:00
giles
ce587b9e43 Update shared: auth routes to federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:42:36 +00:00
giles
82968a366f Rename coop to blog in app code and config
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
- App name: "coop" → "blog"
- coop_context() → blog_context()
- coop_url → blog_url imports
- app_url("coop") → app_url("blog")
- Config keys: coop_root/coop_title → market_root/market_title
- Sync shared submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:33:07 +00:00
giles
065147569c Update COOP_DIR to /root/rose-ash in CI workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
Infra files (.env, docker-compose.yml, _config) moved from ~/coop to ~/rose-ash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:44:07 +00:00
giles
d76f985902 Update shared submodule — add list_marketplaces
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:35:14 +00:00
giles
7453ff845c Update shared: add updated timestamp for AP edits
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:41:28 +00:00
giles
1e8b72e36d Update shared: allow repeated Update activities
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:36:25 +00:00
giles
3f44d513c0 Update shared: AP_DOMAIN default to federation.rose-ash.com
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:50 +00:00
giles
2752f735ba Update shared: origin_app isolation for EventProcessor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:59:40 +00:00
giles
b9b8bbd73d Update shared: origin_app isolation for EventProcessor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:57:54 +00:00
giles
9515e411fa Update shared: debug logging for event processor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:49:47 +00:00
giles
859cf52b2b Update shared submodule: versioned AP object IDs + restored templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:26:48 +00:00
giles
48a381eabb Update shared submodule (remove dead code)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:11:36 +00:00
giles
49a9fd7552 Update shared submodule (remove dead cart template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:05:31 +00:00
giles
1a8a5f4487 Store cross-app cart_sid on login for cart adoption
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Read cart_sid from query params on login page and save to session
so the verify route can emit adoption event with the correct session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:46:27 +00:00
giles
78fb9d8dd8 Update shared submodule (cart sign-in fix)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:38:12 +00:00
giles
9182c8d0b5 Switch to unified AP activity bus
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
emit_event → emit_activity for login event. Update shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:20:11 +00:00
giles
5e9ab507be Tech debt cleanup: update README, fix comments, sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:35:47 +00:00
giles
98ab24f517 Update shared: add fediverse social tables and protocols
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:16:00 +00:00
giles
324cd9cf5b Update shared: fix duplicate AP posts + stable object IDs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:18:24 +00:00
giles
36c33d9ce2 Use full post body in AP notes, not just excerpt
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Federated content has no character limit — use the complete plaintext
body so followers see the full post in their timeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:40:19 +00:00
giles
4c44fc64c5 Enrich AP posts: Note type, images, hashtags, HTML excerpt
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
- Switch object type from Article to Note (Mastodon first-class support)
- Include title + excerpt as HTML content with "Read more" link
- Feature image + up to 3 inline images as AP attachments
- Post tags as AP Hashtag objects with inline links in content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:35:40 +00:00
giles
8cc17e195d Update shared: fix AP Delete Tombstone id mismatch
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:25:58 +00:00
giles
ecb8639829 Update shared: widget Phase 2 nav templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:14:29 +00:00
giles
a02765dffa Update shared: fix AP object id domain for Mastodon
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:53:19 +00:00
giles
e467946f1d Update shared: inline federation publish + AP delivery fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:28:10 +00:00
giles
fe3bc9d893 Inline federation publication in ghost_sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Replace emit_event("post.published/updated/unpublished") with direct
try_publish() calls. AP activities are now created at write time,
fixing the race condition where multiple EventProcessors competed
for federation events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:55:48 +00:00
giles
c3c878f781 Update shared: fix AP edit/unpublish MultipleResultsFound crash
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:49:27 +00:00
giles
ceacf7a56e Fix _upsert_post to return (post, old_status) tuple
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m25s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:40:27 +00:00
giles
0d18fd8fd9 Track status changes for unpublish + edit federation events
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
- _upsert_post returns (post, old_status) to detect status transitions
- Emit post.unpublished when published→draft (triggers Delete activity)
- Emit post.updated only when already-published posts are edited
- Emit post.published only for new publishes (not re-syncs)
- Same logic for pages via sync_single_page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:27:04 +00:00
giles
582882205f Update shared submodule: federation handler debug logging
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:13:02 +00:00
giles
507200893d Fix _upsert_post savepoint: add() inside begin_nested()
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
begin_nested() auto-flushes on entry which triggers the INSERT before
the savepoint is active. Move sess.add() inside the savepoint block
and split into update vs insert paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:59:54 +00:00
giles
9d6a458115 Wire real FederationService + add page federation events
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
- Blog app now registers SqlFederationService (was stub/no-op)
- sync_single_page emits post.published/updated events for pages
- Updated shared submodule: fix sign_request in AP delivery handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:52:36 +00:00
giles
346089973f Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:47:06 +00:00
giles
80e4f21b0b Fix page creation labels and webhook race condition
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
- Updated shared submodule: page-aware blog_new template labels
- _upsert_post: handle concurrent webhook INSERTs via savepoint + retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:32:05 +00:00
giles
954b6cc06a Set page flag in sync_single_page to ensure is_page=true
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Ghost /pages/ endpoint may not include "page": true in the response.
Explicitly set it so _upsert_post correctly marks is_page=true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:23:19 +00:00
giles
85acc68840 Update shared submodule: page-aware labels in edit/settings templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:13:56 +00:00
giles
c40769d24a Update shared submodule: skip blog chrome on pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:10:45 +00:00
giles
23fe8c233e Fix page editing: use Ghost /pages/ API for pages, not /posts/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Ghost has separate /posts/ and /pages/ endpoints. All admin functions
(get_post_for_edit, update_post, update_post_settings, sync) were
hardcoded to /posts/, causing 404s when editing pages. Now checks
is_page from post_data and uses the correct endpoint.

Also uses sync_single_page (not sync_single_post) after page saves
to prevent IntegrityError from mismatched fetch/upsert paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:03:41 +00:00
giles
7f52f59fe0 Update shared submodule: fix adopt_entries login bug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:20:50 +00:00
giles
53dff0d41b Update shared submodule + emit post events for federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
- Emit post.published/post.updated events from Ghost webhook sync
- Updated shared with federation handlers, delivery, anchoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:59:53 +00:00
giles
234a5f797d Wire federation service stub and update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
- Register StubFederationService in services/__init__.py
- Add federation to CI sibling list
- Add federation URL to app-config.yaml
- Update shared submodule with federation models/contracts/services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:11:20 +00:00
giles
fb93af067c Update shared submodule: ticket +/- quantity support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:53:35 +00:00
giles
a8e0d8f257 Decoupling audit: remove coop_api, fix blog admin calendar imports
Some checks are pending
Build and Deploy / build-and-deploy (push) Waiting to run
- Delete coop_api.py (dead internal API endpoint)
- Replace cross-app calendar imports with shared service calls
- Update shared submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:15:23 +00:00
giles
2dc9bf220b Include ticket counts in cart badge totals
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:01:27 +00:00
giles
89112d0cec Fix indentation error in except block
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:01:20 +00:00
giles
5c203cb99c Fix: update local _nav.html to use widget-driven nav
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m5s
The blog app's local templates/_types/post/_nav.html was shadowing
the shared version due to ChoiceLoader priority. Updated local copy
to use container_nav_widgets while keeping the blog-specific admin
cog link. Removed debug logging from context processor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:57:30 +00:00
giles
f625c42118 Update shared submodule: template debug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:32:52 +00:00
giles
7ec38b87f8 Add widget debug logging to post context processor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:29:07 +00:00
giles
4f6e5d234d Update shared submodule to widget registry commit
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:00:29 +00:00
giles
8af7c69090 Decouple blog UI via widget registry
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Replace explicit calendar/market service calls in post routes, auth
routes, and listing cards with widget-driven iteration. Zero cross-domain
imports remain in blog bp layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:04:26 +00:00
giles
bb60835c58 Update shared submodule: tickets & bookings account pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:44:24 +00:00
giles
909ae0e2d6 Add tickets & bookings account sub-pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
New GET /auth/tickets/ and /auth/bookings/ routes with HTMX support.
Update shared submodule with TicketDTO, service methods, nav links,
and panel templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:06:30 +00:00
giles
5301459201 Update shared submodule: fix category selector highlighting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:29:07 +00:00
giles
6a332b95c0 Update shared submodule: select_colours Jinja global
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:17:24 +00:00
giles
08e441f2ff Update shared submodule: fix menu item highlighting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:57:10 +00:00
giles
895c323968 Update shared submodule: delete button + quantity clamp in cart_item
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:43:41 +00:00
giles
9dc5877fc9 Update shared submodule: cart_quantity_url template support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:32:37 +00:00
giles
f9e39333bf Replace direct MarketPlace imports with MarketService calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Market CRUD in blog now delegates to services.market.create_marketplace()
and soft_delete_marketplace() instead of importing MarketPlace directly.
Adds TODO comments on Calendar imports in admin routes (deferred to
admin UI rework). Updates shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:57:54 +00:00
giles
6f063665b0 Update shared submodule: DTO template compatibility fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:05:43 +00:00
giles
e6ccdc423d Update shared submodule: revert extend_existing workaround
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:52:03 +00:00
giles
52a4f4ad43 Remove glue submodule: models moved to shared/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
The glue layer's models (MenuNode, ContainerRelation), services
(navigation, relationships), and event handlers have been absorbed
into shared/. The glue submodule caused duplicate SQLAlchemy table
registration for 'menu_nodes'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:41:11 +00:00
giles
f364448131 Update shared submodule: fix duplicate table error for MenuNode
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:34:42 +00:00
giles
4155df7e7c Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Replace direct Calendar, MarketPlace, and Post model queries with typed
service calls (services.blog, services.calendar, services.market,
services.cart). Blog registers all 4 services via domain_services_fn
with has() guards for composable deployment.

Key changes:
- app.py: use domain_services_fn instead of inline service registration
- admin routes: MarketPlace queries → services.market.marketplaces_for_container()
- entry_associations: CalendarEntryPost → services.calendar.entry_ids_for_content()
- markets service: Post query → services.blog.get_post_by_id/slug()
- posts_data, post routes: use calendar/market/cart services
- menu_items: glue imports → shared imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:30:14 +00:00
giles
e1f4471002 Update shared submodule: fix ticket_types lazy-load
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:02:20 +00:00
giles
2efc05957e Fix cart badge to include calendar entries in count
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Context processor now sums product count + calendar_count from the
cart API, so the cart badge shows the correct total on all pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:49:42 +00:00
giles
c537770172 Decouple blog: use shared.models for all cross-app imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
- Replace all imports from cart.models, market.models, events.models
  with shared.models equivalents
- Convert blog/models/ghost_content.py to re-export stub
- Update shared + glue submodule pointers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:57:56 +00:00
giles
1ea2950310 Remove 81 identical blog template overrides of shared templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
These were duplicates of files in shared/browser/templates/ and
silently prevented shared updates from taking effect (as happened
with the auth nav orders link fix). 8 blog-specific overrides with
genuine differences are kept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:55:45 +00:00
giles
aa06082ad2 Remove blog auth nav override, use shared version
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:52:00 +00:00
giles
4d31123635 Fix orders link: use plain anchor for cross-domain cart navigation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
The orders link was using links.link macro which adds hx-get, causing
htmx:invalidPath because cart.rose-ash.com is cross-origin. Use a
plain <a> tag for this cross-domain link instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:48:05 +00:00
giles
122bf90714 Update shared submodule: fix orders link htmx interception
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:30:55 +00:00
giles
e6409128d3 Update shared submodule: use coop_url for auth links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:18:42 +00:00
giles
d2fa8c17bb Update shared submodule: fix market nav link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:57:09 +00:00
giles
803ccfb58d Update shared submodule: add page_config to get_checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:38:18 +00:00
giles
15eda69639 Update shared submodule: market_product_url for correct product URLs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:19:55 +00:00
giles
d2e6dd8b1d Update shared submodule: add page_config to SumUp checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:52:17 +00:00
giles
dd7fbc89ce Add HTML email templates for magic link sign-in
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Extract inline email body into separate Jinja2 templates
(_email/magic_link.html and .txt) with a styled sign-in
button and fallback plain-text link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:09:49 +00:00
giles
23b1e35eac Update shared submodule: fix doubled URLs in |host filter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:46:06 +00:00
giles
45b748eb6d README: replace vague cross-app section with actual code dependencies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
List specific model imports, glue services, internal APIs, and
domain events that blog code actually references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:47:45 +00:00
giles
43bc03836d Update shared + glue submodule pointers (README additions)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:36:25 +00:00
giles
7d20f67d99 Rewrite README for post-decoupling architecture
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Document submodules, models, cross-app integration (event-driven
login adoption), and accurate directory structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:28:50 +00:00
giles
6fb60d43c6 Phase 5: Update shared + glue submodule pointers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
shared: migration to drop cross-domain FK constraints
glue: order lifecycle services, cart adoption, login/order handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:11:58 +00:00
giles
a24e5c6407 Phase 5: Replace HTTP cart adoption with event-driven adoption
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Emit user.logged_in event instead of HTTP POST to /internal/cart/adopt.
Event is emitted inside the last_login_at transaction for atomicity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:35:55 +00:00
giles
75a5d520e8 Add attach/detach glue calls to menu items, markets, and page config CRUD
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Wire up ContainerRelation tracking via attach_child/detach_child in:
- menu_items: create, update (re-link on post change), delete
- markets: create (including revive), soft_delete
- page config: creation in update_features and update_sumup routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:52:12 +00:00
giles
70ef1910c1 Update shared submodule to include glue layer + MenuItem fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:03:32 +00:00
giles
05d9e70e8a Add glue layer: MenuNode replaces MenuItem, remove /internal/menu-items API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
- Context processor: get_navigation_tree() replaces get_all_menu_items()
- Menu admin service: MenuItem → MenuNode (container_type/container_id pattern)
- Remove /internal/menu-items endpoint (other apps query menu_nodes directly)
- Remove menu_items relationship from Post model
- Templates: item.post.X → item.X
- Add glue submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:37:43 +00:00
giles
da3481196b ci: clean all sibling dirs before copying to fix stale table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
Previous runs left self-copies (e.g. blog/blog/) that caused
'Table already defined' errors. Split into two loops: first rm -rf
all sibling dirs, then copy only non-self siblings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:31:46 +00:00
giles
9d13230465 CI: skip copying own models to avoid duplicate SQLAlchemy table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m52s
Each app has its own models/ at the root (imported as bare `models.X`).
The CI copy was also creating {app}/models/ (imported as `{app}.models.X`),
causing SQLAlchemy to see the same table defined twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:14:46 +00:00
giles
73160377f1 Update shared submodule: import all model packages at startup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:01:40 +00:00
giles
977e8d99af CI: use git archive for sibling models (atomic, race-safe)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
The cp approach failed when sibling repos were mid-update from
their own CI runs. git archive reads directly from git objects,
and git fetch ensures origin/decoupling is available even if the
sibling working tree is on a different branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:10:58 +00:00
giles
bae37d97ae CI: copy sibling app models into build context for cross-domain imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Phases 1-3 split models by domain ownership, but cross-app imports
still exist (e.g. cart imports market.models.CartItem). In Docker
each app only has its own code. The CI step now copies sibling app
model packages into the build context before docker build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:01:50 +00:00
giles
bebb81a0f4 Update shared submodule: merge diverged alembic heads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:27:20 +00:00
giles
3e1aa7197b Fix alembic.ini missing + entrypoint CWD safety
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
Update shared submodule (adds alembic.ini for migrations).
Use subshell for alembic so CWD is never affected by cd shared,
preventing cascading config path errors if migration fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:17:28 +00:00
giles
9b180b364b Add PYTHONPATH=/app so Hypercorn spawn workers find app module
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m30s
Hypercorn spawns worker processes via multiprocessing spawn, which
starts a fresh Python interpreter without the parent's sys.path
modifications from path_setup.py. Setting PYTHONPATH=/app ensures
the worker can import app.py and all project packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:01:18 +00:00
giles
9e6f138f45 Update shared submodule: rename logging → log_config
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m45s
Fixes stdlib logging shadow that caused circular import in Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:55:44 +00:00
giles
a1eaba5119 Replace shared_lib submodule with shared for decoupling deploy
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
- Swap shared_lib submodule → shared (tracking decoupling branch)
- Dockerfile: shared_lib/ → shared/, remove bp symlink hack
- entrypoint.sh: cd shared for alembic upgrade head
- CI: trigger on decoupling branch, use dynamic ref_name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:29:21 +00:00
giles
a01016d8d5 feat: decouple blog from shared_lib, add app-owned models
Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Blog-owned models in blog/models/ (ghost_content, snippet, tag_group)
- Re-export shims for shared models (user, kv, magic_link, menu_item)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- No more cross-app post_id FKs in calendar/market/page_config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:46:31 +00:00
93 changed files with 2214 additions and 1528 deletions

View File

@@ -2,13 +2,13 @@ name: Build and Deploy
on:
push:
branches: [main]
branches: [main, decoupling]
env:
REGISTRY: registry.rose-ash.com:5000
IMAGE: blog
REPO_DIR: /root/rose-ash/blog
COOP_DIR: /root/coop
COOP_DIR: /root/rose-ash
jobs:
build-and-deploy:
@@ -36,9 +36,23 @@ jobs:
run: |
ssh "root@$DEPLOY_HOST" "
cd ${{ env.REPO_DIR }}
git fetch origin main
git reset --hard origin/main
git fetch origin ${{ github.ref_name }}
git reset --hard origin/${{ github.ref_name }}
git submodule update --init --recursive
# Clean ALL sibling dirs (including stale self-copies from previous runs)
for sibling in blog market cart events federation; do
rm -rf \$sibling
done
# Copy non-self sibling models for cross-domain imports
for sibling in blog market cart events federation; do
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
repo=/root/rose-ash/\$sibling
if [ -d \$repo/.git ]; then
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
mkdir -p \$sibling
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
fi
done
"
- name: Build and push image

5
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "shared_lib"]
path = shared_lib
[submodule "shared"]
path = shared
url = https://git.rose-ash.com/coop/shared.git
branch = decoupling

View File

@@ -3,9 +3,9 @@
# ---------- Stage 1: Build editor JS/CSS ----------
FROM node:20-slim AS editor-build
WORKDIR /build
COPY shared_lib/editor/package.json shared_lib/editor/package-lock.json* ./
COPY shared/editor/package.json shared/editor/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY shared_lib/editor/ ./
COPY shared/editor/ ./
RUN NODE_ENV=production node build.mjs
# ---------- Stage 2: Python runtime ----------
@@ -13,6 +13,7 @@ FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
@@ -25,16 +26,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared_lib/requirements.txt ./requirements.txt
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .
# Copy built editor assets from stage 1
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared_lib/static/scripts/
# Link app blueprints into the shared library's namespace
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/
# ---------- Runtime setup ----------
COPY entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -1,60 +1,60 @@
# Blog App
# Blog App (Coop)
Blog and content management application for the Rose Ash cooperative platform.
Blog, authentication, and content management service for the Rose Ash cooperative platform. Handles Ghost CMS integration, user auth, and admin settings.
## Overview
## Architecture
This is the **blog** service extracted from the Rose Ash (Suma Browser) monolith.
It handles:
One of five Quart microservices sharing a single PostgreSQL database:
- **Blog**: Ghost CMS integration for browsing, creating, and editing posts
- **Auth**: Magic link authentication and user account management
- **Admin/Settings**: Administrative interface and settings management
- **Menu Items**: Navigation menu item management
- **Snippets**: Reusable content snippet management
- **Internal API**: Server-to-server endpoints for cross-app data sharing
| App | Port | Domain |
|-----|------|--------|
| **blog (coop)** | 8000 | Auth, blog, admin, menus, snippets |
| market | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
## Tech Stack
## Structure
- **Quart** (async Flask) with HTMX
- **SQLAlchemy 2.0** (async) with PostgreSQL
- **Redis** for page caching
- **Ghost CMS** for blog content
```
app.py # Application factory (create_base_app + blueprints)
path_setup.py # Adds project root + app dir to sys.path
config/app-config.yaml # App URLs, feature flags, SumUp config
models/ # Blog-domain models (+ re-export stubs for shared models)
bp/ # Blueprints
auth/ # Magic link login, account, newsletters
blog/ # Post listing, Ghost CMS sync
post/ # Single post view and admin
admin/ # Settings admin interface
menu_items/ # Navigation menu management
snippets/ # Reusable content snippets
templates/ # Jinja2 templates
services/ # register_domain_services() — wires blog + calendar + market + cart
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
## Cross-Domain Communication
All inter-app communication uses typed service contracts (no HTTP APIs):
- `services.calendar.*` — calendar/entry queries via CalendarService protocol
- `services.market.*` — marketplace queries via MarketService protocol
- `services.cart.*` — cart summary via CartService protocol
- `services.federation.*` — AP publishing via FederationService protocol
- `shared.services.navigation` — site navigation tree
## Domain Events
- `auth/routes.py` emits `user.logged_in` via `shared.events.emit_event`
- Ghost sync emits `post.published` / `post.updated` for federation
## Running
```bash
# Set environment variables (see .env.example)
export APP_MODULE=app:app
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
# Run migrations
alembic upgrade head
# Start the server
alembic -c shared/alembic.ini upgrade head
hypercorn app:app --bind 0.0.0.0:8000
```
## Docker
```bash
docker build -t blog .
docker run -p 8000:8000 --env-file .env blog
```
## Directory Structure
```
app.py # Application factory and entry point
bp/ # Blueprints
auth/ # Authentication (magic links, account)
blog/ # Blog listing, Ghost CMS integration
post/ # Individual post viewing and admin
admin/ # Settings admin interface
menu_items/ # Navigation menu management
snippets/ # Content snippet management
coop_api.py # Internal API endpoints
templates/ # Jinja2 templates
_types/ # Feature-specific templates
entrypoint.sh # Docker entrypoint (migrations + server start)
Dockerfile # Container build definition
```

0
__init__.py Normal file
View File

83
app.py
View File

@@ -1,55 +1,82 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app
from config import config
from models import KV
from shared.infrastructure.factory import create_base_app
from shared.config import config
from shared.models import KV
from suma_browser.app.bp import (
register_auth_bp,
from bp import (
register_blog_bp,
register_admin,
register_menu_items,
register_snippets,
register_coop_api,
register_fragments,
)
async def coop_context() -> dict:
async def blog_context() -> dict:
"""
Coop app context processor.
Blog app context processor.
- menu_items: direct DB query (coop owns this data)
- cart_count/cart_total: fetched from cart internal API
- cart_count/cart_total: via cart service (shared DB)
- cart_mini_html / auth_menu_html / nav_tree_html: pre-fetched fragments
"""
from shared.context import base_context
from suma_browser.app.bp.menu_items.services.menu_items import get_all_menu_items
from shared.internal_api import get as api_get
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
ctx = await base_context()
# Coop owns menu_items — query directly
ctx["menu_items"] = await get_all_menu_items(g.s)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data from cart app API
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
if cart_data:
ctx["cart_count"] = cart_data.get("count", 0)
ctx["cart_total"] = cart_data.get("total", 0)
else:
ctx["cart_count"] = 0
ctx["cart_total"] = 0
# Cart data via service (replaces cross-app HTTP API)
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
# Pre-fetch cross-app HTML fragments concurrently
# (fetch_fragment auto-skips when inside a fragment request to prevent circular deps)
user = getattr(g, "user", None)
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
auth_params = {"email": user.email} if user else {}
nav_params = {"app_name": "blog", "path": request.path}
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", auth_params or None),
("blog", "nav-tree", nav_params),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
return ctx
def create_app() -> "Quart":
app = create_base_app("coop", context_fn=coop_context)
from services import register_domain_services
app = create_base_app(
"blog",
context_fn=blog_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
@@ -59,8 +86,6 @@ def create_app() -> "Quart":
])
# --- blueprints ---
app.register_blueprint(register_auth_bp())
app.register_blueprint(
register_blog_bp(
url_prefix=config()["blog_root"],
@@ -72,9 +97,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
# Internal API (server-to-server, CSRF-exempt)
app.register_blueprint(register_coop_api())
app.register_blueprint(register_fragments())
# --- KV admin endpoints ---
@app.get("/settings/kv/<key>")

View File

@@ -1,6 +1,5 @@
from .auth.routes import register as register_auth_bp
from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .coop_api import register as register_coop_api
from .fragments import register_fragments

View File

@@ -11,10 +11,10 @@ from quart import (
request,
jsonify
)
from suma_browser.app.redis_cacher import clear_all_cache
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
from config import config
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.config import config
from datetime import datetime
def register(url_prefix):

View File

@@ -1,313 +0,0 @@
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from quart import (
Blueprint,
request,
render_template,
make_response,
redirect,
url_for,
session as qsession,
g,
current_app,
)
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from ..blog.ghost.ghost_sync import (
sync_member_to_ghost,
)
from db.session import get_session
from models import User, MagicLink, UserNewsletter
from models.ghost_membership_entities import GhostNewsletter
from config import config
from utils import host_url
from shared.urls import coop_url
from sqlalchemy.orm import selectinload
from suma_browser.app.redis_cacher import clear_cache
from shared.cart_identity import current_cart_identity
from shared.internal_api import post as api_post
from .services import pop_login_redirect_target, store_login_redirect_target
from .services.auth_operations import (
get_app_host,
get_app_root,
send_magic_email,
load_user_by_id,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
oob = {
"oob_extends": "oob_elements.html",
"extends": "_types/root/_index.html",
"parent_id": "root-header-child",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"parent_header": "_types/root/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html"
}
def register(url_prefix="/auth"):
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
@auth_bp.before_request
def route():
pass
SESSION_USER_KEY = "uid"
@auth_bp.context_processor
def context():
return {
"oob": oob,
}
# NOTE: load_current_user moved to shared/user_loader.py
# and registered in shared/factory.py as an app-level before_request
@auth_bp.get("/login/")
async def login_form():
store_login_redirect_target()
if g.get("user"):
return redirect(coop_url("/"))
return await render_template("_types/auth/login.html")
@auth_bp.get("/account/")
async def account():
from suma_browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(host_url(url_for("auth.login_form")))
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
# For now, render full template for both HTMX and normal requests
# Determine which template to use based on request type
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/auth/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template(
"_types/auth/_oob_elements.html",
)
return await make_response(html)
@auth_bp.get("/newsletters/")
async def newsletters():
from suma_browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(host_url(url_for("auth.login_form")))
# Fetch all newsletters, sorted alphabetically
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
# Fetch user's subscription states
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
# Build list with subscription state for template
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
if not is_htmx_request():
html = await render_template(
"_types/auth/index.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
else:
html = await render_template(
"_types/auth/_oob_elements.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
return await make_response(html)
@auth_bp.post("/start/")
@clear_cache(tag_scope="user", clear_user=True)
async def start_login():
# 1. Get and validate email
form = await request.form
email_input = form.get("email") or ""
is_valid, email = validate_email(email_input)
if not is_valid:
return (
await render_template(
"_types/auth/login.html",
error="Please enter a valid email address.",
email=email_input,
),
400,
)
# 2. Create/find user and issue magic link token
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
g.s.commit()
# 3. Build the magic link URL
magic_url = host_url(url_for("auth.magic", token=token))
# 4. Try sending the email
email_error = None
try:
await send_magic_email(email, magic_url)
except Exception as e:
print("EMAIL SEND FAILED:", repr(e))
email_error = (
"We couldn't send the email automatically. "
"Please try again in a moment."
)
# 5. Render "check your email" page
return await render_template(
"_types/auth/check_email.html",
email=email,
email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
now = datetime.now(timezone.utc)
user_id: int | None = None
# ---- Step 1: Validate & consume magic link ----
try:
async with get_session() as s:
async with s.begin():
user, error = await validate_magic_link(s, token)
if error:
return (
await render_template(
"_types/auth/login.html",
error=error,
),
400,
)
user_id = user.id
# Try to ensure Ghost membership inside this txn
try:
if not user.ghost_id:
await sync_member_to_ghost(s, user.id)
except Exception:
current_app.logger.exception(
"[auth] Ghost upsert failed for user_id=%s", user.id
)
raise
except Exception:
# Any DB/Ghost error → generic failure
return (
await render_template(
"_types/auth/login.html",
error="Could not sign you in right now. Please try again.",
),
502,
)
# At this point:
# - magic link is consumed
# - user_id is valid
# - Ghost membership is ensured or we already returned 502
assert user_id is not None # for type checkers / sanity
# Figure out any anonymous session we want to adopt
ident = current_cart_identity()
anon_session_id = ident.get("session_id")
# ---- Step 3: best-effort local update (non-fatal) ----
try:
async with get_session() as s:
async with s.begin():
u2 = await s.get(User, user_id)
if u2:
u2.last_login_at = now
# s.begin() will commit on successful exit
except SQLAlchemyError:
current_app.logger.exception(
"[auth] non-fatal DB update after Ghost upsert for user_id=%s", user_id
)
# Adopt cart + calendar entries via cart app internal API
if anon_session_id:
await api_post(
"cart",
"/internal/cart/adopt",
json={"user_id": user_id, "session_id": anon_session_id},
)
# ---- Finalize login ----
qsession[SESSION_USER_KEY] = user_id
# Redirect back to where they came from, if we stored it.
redirect_url = pop_login_redirect_target()
return redirect(redirect_url, 303)
@auth_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):
return "", 401
result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
UserNewsletter.newsletter_id == newsletter_id,
)
)
un = result.scalar_one_or_none()
if un:
un.subscribed = not un.subscribed
else:
un = UserNewsletter(
user_id=g.user.id,
newsletter_id=newsletter_id,
subscribed=True,
)
g.s.add(un)
await g.s.flush()
return await render_template(
"_types/auth/_newsletter_toggle.html",
un=un,
)
@auth_bp.post("/logout/")
async def logout():
qsession.pop(SESSION_USER_KEY, None)
return redirect(coop_url("/"))
return auth_bp

View File

@@ -1,24 +0,0 @@
from .login_redirect import pop_login_redirect_target, store_login_redirect_target
from .auth_operations import (
get_app_host,
get_app_root,
send_magic_email,
load_user_by_id,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
__all__ = [
"pop_login_redirect_target",
"store_login_redirect_target",
"get_app_host",
"get_app_root",
"send_magic_email",
"load_user_by_id",
"find_or_create_user",
"create_magic_link",
"validate_magic_link",
"validate_email",
]

View File

@@ -1,239 +0,0 @@
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from quart import current_app, request, g
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models import User, MagicLink, UserNewsletter
from config import config
def get_app_host() -> str:
"""Get the application host URL from config or environment."""
host = (
config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000"
).rstrip("/")
return host
def get_app_root() -> str:
"""Get the application root path from request context."""
root = (g.root).rstrip("/")
return root
async def send_magic_email(to_email: str, link_url: str) -> None:
"""
Send magic link email via SMTP if configured, otherwise log to console.
Args:
to_email: Recipient email address
link_url: Magic link URL to include in email
Raises:
Exception: If SMTP sending fails
"""
host = os.getenv("SMTP_HOST")
port = int(os.getenv("SMTP_PORT") or "587")
username = os.getenv("SMTP_USER")
password = os.getenv("SMTP_PASS")
mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com"
subject = "Your sign-in link"
body = f"""Hello,
Click this link to sign in:
{link_url}
This link will expire in 15 minutes.
If you did not request this, you can ignore this email.
"""
if not host or not username or not password:
# Fallback: log to console
current_app.logger.warning(
"SMTP not configured. Printing magic link to console for %s: %s",
to_email,
link_url,
)
print(f"[DEV] Magic link for {to_email}: {link_url}")
return
# Lazy import to avoid dependency unless used
import aiosmtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = mail_from
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(body)
is_secure = port == 465 # implicit TLS if true
if is_secure:
# implicit TLS (like nodemailer secure: true)
smtp = aiosmtplib.SMTP(
hostname=host,
port=port,
use_tls=True,
username=username,
password=password,
)
else:
# plain connect then STARTTLS (like secure: false but with TLS upgrade)
smtp = aiosmtplib.SMTP(
hostname=host,
port=port,
start_tls=True,
username=username,
password=password,
)
async with smtp:
await smtp.send_message(msg)
async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""
Load a user by ID with labels and newsletters eagerly loaded.
Args:
session: Database session
user_id: User ID to load
Returns:
User object or None if not found
"""
stmt = (
select(User)
.options(
selectinload(User.labels),
selectinload(User.user_newsletters).selectinload(
UserNewsletter.newsletter
),
)
.where(User.id == user_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_or_create_user(session: AsyncSession, email: str) -> User:
"""
Find existing user by email or create a new one.
Args:
session: Database session
email: User email address (should be lowercase and trimmed)
Returns:
User object (either existing or newly created)
"""
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
user = User(email=email)
session.add(user)
await session.flush() # Ensure user.id exists
return user
async def create_magic_link(
session: AsyncSession,
user_id: int,
purpose: str = "signin",
expires_minutes: int = 15,
) -> Tuple[str, datetime]:
"""
Create a new magic link token for authentication.
Args:
session: Database session
user_id: User ID to create link for
purpose: Purpose of the link (default: "signin")
expires_minutes: Minutes until expiration (default: 15)
Returns:
Tuple of (token, expires_at)
"""
token = secrets.token_urlsafe(32)
expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
ml = MagicLink(
token=token,
user_id=user_id,
purpose=purpose,
expires_at=expires,
ip=request.headers.get("x-forwarded-for", request.remote_addr),
user_agent=request.headers.get("user-agent"),
)
session.add(ml)
return token, expires
async def validate_magic_link(
session: AsyncSession,
token: str,
) -> Tuple[Optional[User], Optional[str]]:
"""
Validate and consume a magic link token.
Args:
session: Database session (should be in a transaction)
token: Magic link token to validate
Returns:
Tuple of (user, error_message)
- If user is None, error_message contains the reason
- If user is returned, the link was valid and has been consumed
"""
now = datetime.now(timezone.utc)
ml = await session.scalar(
select(MagicLink)
.where(MagicLink.token == token)
.with_for_update()
)
if not ml or ml.purpose != "signin":
return None, "Invalid or expired link."
if ml.used_at or ml.expires_at < now:
return None, "This link has expired. Please request a new one."
user = await session.get(User, ml.user_id)
if not user:
return None, "User not found."
# Mark link as used
ml.used_at = now
return user, None
def validate_email(email: str) -> Tuple[bool, str]:
"""
Validate email address format.
Args:
email: Email address to validate
Returns:
Tuple of (is_valid, normalized_email)
"""
email = email.strip().lower()
if not email or "@" not in email:
return False, email
return True, email

View File

@@ -1,45 +0,0 @@
from urllib.parse import urlparse
from quart import session
from shared.urls import coop_url
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
def store_login_redirect_target() -> None:
from quart import request
target = request.args.get("next")
if not target:
ref = request.referrer or ""
try:
parsed = urlparse(ref)
target = parsed.path or ""
except Exception:
target = ""
if not target:
return
# Accept both relative paths and absolute URLs (cross-app redirects)
if target.startswith("http://") or target.startswith("https://"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
elif target.startswith("/") and not target.startswith("//"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
def pop_login_redirect_target() -> str:
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
if not path or not isinstance(path, str):
return coop_url("/auth/")
# Absolute URL: return as-is (cross-app redirect)
if path.startswith("http://") or path.startswith("https://"):
return path
# Relative path: must start with / and not //
if path.startswith("/") and not path.startswith("//"):
return coop_url(path)
return coop_url("/auth/")

View File

@@ -12,9 +12,9 @@ from quart import (
)
from sqlalchemy import select, delete
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.redis_cacher import invalidate_tag_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag

View File

@@ -2,10 +2,10 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import (
from shared.browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs,
)
from suma_browser.app.filters.query_types import BlogQuery
from shared.browser.app.filters.query_types import BlogQuery
def decode() -> BlogQuery:

View File

@@ -13,7 +13,7 @@ import httpx
from quart import Blueprint, request, jsonify, g
from sqlalchemy import select, or_
from suma_browser.app.authz import require_admin, require_login
from shared.browser.app.authz import require_admin, require_login
from models import Snippet
from .ghost_admin_token import make_ghost_admin_jwt

View File

@@ -30,10 +30,11 @@ def _check(resp: httpx.Response) -> None:
resp.raise_for_status()
async def get_post_for_edit(ghost_id: str) -> dict | None:
"""Fetch a single post by Ghost ID, including lexical source."""
async def get_post_for_edit(ghost_id: str, *, is_page: bool = False) -> dict | None:
"""Fetch a single post/page by Ghost ID, including lexical source."""
resource = "pages" if is_page else "posts"
url = (
f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/"
f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
"?formats=lexical,html,mobiledoc&include=newsletters"
)
async with httpx.AsyncClient(timeout=30) as client:
@@ -41,7 +42,7 @@ async def get_post_for_edit(ghost_id: str) -> dict | None:
if resp.status_code == 404:
return None
_check(resp)
return resp.json()["posts"][0]
return resp.json()[resource][0]
async def create_post(
@@ -114,6 +115,7 @@ async def update_post(
newsletter_slug: str | None = None,
email_segment: str | None = None,
email_only: bool | None = None,
is_page: bool = False,
) -> dict:
"""Update an existing Ghost post. Returns the updated post dict.
@@ -141,9 +143,10 @@ async def update_post(
post_body["status"] = status
if email_only:
post_body["email_only"] = True
payload = {"posts": [post_body]}
resource = "pages" if is_page else "posts"
payload = {resource: [post_body]}
url = f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/"
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
if newsletter_slug:
url += f"?newsletter={newsletter_slug}"
if email_segment:
@@ -151,7 +154,7 @@ async def update_post(
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.put(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()["posts"][0]
return resp.json()[resource][0]
_SETTINGS_FIELDS = (
@@ -178,22 +181,24 @@ _SETTINGS_FIELDS = (
async def update_post_settings(
ghost_id: str,
updated_at: str,
is_page: bool = False,
**kwargs,
) -> dict:
"""Update Ghost post settings (slug, tags, SEO, social, etc.).
"""Update Ghost post/page settings (slug, tags, SEO, social, etc.).
Only non-None keyword args are included in the PUT payload.
Accepts any key from ``_SETTINGS_FIELDS``.
"""
resource = "pages" if is_page else "posts"
post_body: dict = {"updated_at": updated_at}
for key in _SETTINGS_FIELDS:
val = kwargs.get(key)
if val is not None:
post_body[key] = val
payload = {"posts": [post_body]}
url = f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/"
payload = {resource: [post_body]}
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.put(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()["posts"][0]
return resp.json()[resource][0]

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import os
import re
import asyncio
from datetime import datetime
from html import escape as html_escape
from typing import Dict, Any, Optional
import httpx
@@ -13,11 +15,11 @@ from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON colu
from models.ghost_content import (
Post, Author, Tag, PostAuthor, PostTag
)
from models.page_config import PageConfig
from shared.models.page_config import PageConfig
# User-centric membership models
from models import User
from models.ghost_membership_entities import (
from shared.models import User
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
@@ -29,7 +31,7 @@ from urllib.parse import quote
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
from suma_browser.app.utils import (
from shared.browser.app.utils import (
utcnow
)
@@ -164,13 +166,8 @@ async def _upsert_tag(sess: AsyncSession, gt: Dict[str, Any]) -> Tag:
return obj
async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> Post:
res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"]))
obj = res.scalar_one_or_none()
if obj is None:
obj = Post(ghost_id=gp["id"]) # type: ignore[call-arg]
sess.add(obj)
def _apply_ghost_fields(obj: Post, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> None:
"""Apply Ghost API fields to a Post ORM object."""
obj.deleted_at = None # revive if soft-deleted
obj.uuid = gp.get("uuid") or obj.uuid
@@ -213,6 +210,34 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
pt = gp.get("primary_tag")
obj.primary_tag_id = tag_map[pt["id"].strip()].id if (pt and pt["id"] in tag_map) else None # type: ignore[index]
async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> tuple[Post, str | None]:
"""Upsert a post. Returns (post, old_status) where old_status is None for new rows."""
from sqlalchemy.exc import IntegrityError
res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"]))
obj = res.scalar_one_or_none()
old_status = obj.status if obj is not None else None
if obj is not None:
# Row exists — just update
_apply_ghost_fields(obj, gp, author_map, tag_map)
await sess.flush()
else:
# Row doesn't exist — try to insert within a savepoint
obj = Post(ghost_id=gp["id"]) # type: ignore[call-arg]
try:
async with sess.begin_nested():
sess.add(obj)
_apply_ghost_fields(obj, gp, author_map, tag_map)
await sess.flush()
except IntegrityError:
# Race condition: another request inserted this ghost_id.
# Savepoint rolled back; re-select and update.
res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"]))
obj = res.scalar_one()
_apply_ghost_fields(obj, gp, author_map, tag_map)
await sess.flush()
# Backfill user_id from primary author email if not already set
@@ -242,13 +267,13 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
# Auto-create PageConfig for pages
if obj.is_page:
existing_pc = (await sess.execute(
select(PageConfig).where(PageConfig.post_id == obj.id)
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == obj.id)
)).scalar_one_or_none()
if existing_pc is None:
sess.add(PageConfig(post_id=obj.id, features={}))
sess.add(PageConfig(container_type="page", container_id=obj.id, features={}))
await sess.flush()
return obj
return obj, old_status
async def _ghost_find_member_by_email(email: str) -> Optional[dict]:
"""Return first Ghost member with this email, or None."""
@@ -970,6 +995,77 @@ async def fetch_single_tag_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]
return tags[0] if tags else None
def _build_ap_post_data(post, post_url: str, tag_objs: list) -> dict:
"""Build rich AP object_data for a blog post/page.
Produces a Note with HTML content (excerpt), feature image + inline
images as attachments, and tags as AP Hashtag objects.
"""
# Content HTML: title + excerpt + "Read more" link
parts: list[str] = []
if post.title:
parts.append(f"<p><strong>{html_escape(post.title)}</strong></p>")
body = post.plaintext or post.custom_excerpt or post.excerpt or ""
if body:
for para in body.split("\n\n"):
para = para.strip()
if para:
parts.append(f"<p>{html_escape(para)}</p>")
parts.append(f'<p><a href="{html_escape(post_url)}">Read more \u2192</a></p>')
# Hashtag links in content (Mastodon expects them inline too)
if tag_objs:
ht_links = []
for t in tag_objs:
clean = t.slug.replace("-", "")
ht_links.append(
f'<a href="{html_escape(post_url)}tag/{t.slug}/" rel="tag">#{clean}</a>'
)
parts.append(f'<p>{" ".join(ht_links)}</p>')
obj: dict = {
"name": post.title or "",
"content": "\n".join(parts),
"url": post_url,
}
# Attachments: feature image + inline images (max 4)
attachments: list[dict] = []
seen: set[str] = set()
if post.feature_image:
att: dict = {"type": "Image", "url": post.feature_image}
if post.feature_image_alt:
att["name"] = post.feature_image_alt
attachments.append(att)
seen.add(post.feature_image)
if post.html:
for src in re.findall(r'<img[^>]+src="([^"]+)"', post.html):
if src not in seen and len(attachments) < 4:
attachments.append({"type": "Image", "url": src})
seen.add(src)
if attachments:
obj["attachment"] = attachments
# AP Hashtag objects
if tag_objs:
obj["tag"] = [
{
"type": "Hashtag",
"href": f"{post_url}tag/{t.slug}/",
"name": f"#{t.slug.replace('-', '')}",
}
for t in tag_objs
]
return obj
async def sync_single_post(sess: AsyncSession, ghost_id: str) -> None:
gp = await fetch_single_post_from_ghost(ghost_id)
if gp is None:
@@ -998,12 +1094,45 @@ async def sync_single_post(sess: AsyncSession, ghost_id: str) -> None:
tag_obj = await _upsert_tag(sess, pt)
tag_map[pt["id"]] = tag_obj
await _upsert_post(sess, gp, author_map, tag_map)
# auto-commit
post, old_status = await _upsert_post(sess, gp, author_map, tag_map)
# Publish to federation inline (posts, not pages)
if not post.is_page and post.user_id:
from shared.services.federation_publish import try_publish
from shared.infrastructure.urls import app_url
post_url = app_url("blog", f"/{post.slug}/")
post_tags = [tag_map[t["id"]] for t in (gp.get("tags") or []) if t["id"] in tag_map]
if post.status == "published":
activity_type = "Create" if old_status != "published" else "Update"
await try_publish(
sess,
user_id=post.user_id,
activity_type=activity_type,
object_type="Note",
object_data=_build_ap_post_data(post, post_url, post_tags),
source_type="Post",
source_id=post.id,
)
elif old_status == "published" and post.status != "published":
await try_publish(
sess,
user_id=post.user_id,
activity_type="Delete",
object_type="Tombstone",
object_data={
"id": post_url,
"formerType": "Note",
},
source_type="Post",
source_id=post.id,
)
async def sync_single_page(sess: AsyncSession, ghost_id: str) -> None:
gp = await fetch_single_page_from_ghost(ghost_id)
if gp is not None:
gp["page"] = True # Ghost /pages/ endpoint may omit this flag
if gp is None:
res = await sess.execute(select(Post).where(Post.ghost_id == ghost_id))
obj = res.scalar_one_or_none()
@@ -1030,7 +1159,39 @@ async def sync_single_page(sess: AsyncSession, ghost_id: str) -> None:
tag_obj = await _upsert_tag(sess, pt)
tag_map[pt["id"]] = tag_obj
await _upsert_post(sess, gp, author_map, tag_map)
post, old_status = await _upsert_post(sess, gp, author_map, tag_map)
# Publish to federation inline (pages)
if post.user_id:
from shared.services.federation_publish import try_publish
from shared.infrastructure.urls import app_url
post_url = app_url("blog", f"/{post.slug}/")
post_tags = [tag_map[t["id"]] for t in (gp.get("tags") or []) if t["id"] in tag_map]
if post.status == "published":
activity_type = "Create" if old_status != "published" else "Update"
await try_publish(
sess,
user_id=post.user_id,
activity_type=activity_type,
object_type="Note",
object_data=_build_ap_post_data(post, post_url, post_tags),
source_type="Post",
source_id=post.id,
)
elif old_status == "published" and post.status != "published":
await try_publish(
sess,
user_id=post.user_id,
activity_type="Delete",
object_type="Tombstone",
object_data={
"id": post_url,
"formerType": "Note",
},
source_type="Post",
source_id=post.id,
)
async def sync_single_author(sess: AsyncSession, ghost_id: str) -> None:

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
from models.ghost_content import Post, Author, Tag, PostTag
from models.page_config import PageConfig
from shared.models.page_config import PageConfig
from models.tag_group import TagGroup, TagGroupTag

View File

@@ -15,15 +15,15 @@ from quart import (
url_for,
)
from .ghost_db import DBClient # adjust import path
from db.session import get_session
from shared.db.session import get_session
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from .services.pages_data import pages_data
from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.authz import require_admin
from utils import host_url
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.utils import host_url
def register(url_prefix, title):
blogs_bp = Blueprint("blog", __name__, url_prefix)
@@ -80,6 +80,67 @@ def register(url_prefix, title):
@blogs_bp.get("/")
async def home():
"""Render the Ghost page with slug 'home' as the site homepage."""
from ..post.services.post_data import post_data as _post_data
from shared.config import config as get_config
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services as svc
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
p_data = await _post_data("home", g.s, include_drafts=False)
if not p_data:
# Fall back to blog index if "home" page doesn't exist yet
return redirect(host_url(url_for("blog.index")))
g.post_data = p_data
# Build the same context the post blueprint's context_processor provides
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
# Fetch container nav fragments from events + market
paginate_url = url_for(
'blog.post.widget_paginate',
slug=post_slug, widget_domain='calendar',
)
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
"paginate_url": paginate_url,
}
events_nav_html, market_nav_html = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
])
container_nav_html = events_nav_html + market_nav_html
ctx = {
**p_data,
"base_title": f"{get_config()['title']} {p_data['post']['title']}",
"container_nav_html": container_nav_html,
}
# Page cart badge
if p_data["post"].get("is_page"):
ident = current_cart_identity()
page_summary = await svc.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_slug,
)
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
if not is_htmx_request():
html = await render_template("_types/home/index.html", **ctx)
else:
html = await render_template("_types/home/_oob_elements.html", **ctx)
return await make_response(html)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
async def index():
"""Blog listing — moved from / to /index."""
q = decode()
content_type = request.args.get("type", "posts")
@@ -303,6 +364,6 @@ def register(url_prefix, title):
@blogs_bp.get("/drafts/")
async def drafts():
return redirect(host_url(url_for("blog.home")) + "?drafts=1")
return redirect(host_url(url_for("blog.index")) + "?drafts=1")
return blogs_bp

View File

@@ -1,7 +1,9 @@
import re
from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from models.calendars import CalendarEntry, CalendarEntryPost
from shared.infrastructure.fragments import fetch_fragment
from quart import g
async def posts_data(
@@ -85,32 +87,16 @@ async def posts_data(
for post in posts:
post["is_liked"] = False
# Fetch associated entries for each post
# Get all confirmed entries associated with these posts
from sqlalchemy.orm import selectinload
entries_result = await session.execute(
select(CalendarEntry, CalendarEntryPost.post_id)
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
.options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar
.where(
CalendarEntryPost.post_id.in_(post_ids),
CalendarEntryPost.deleted_at.is_(None),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed"
)
.order_by(CalendarEntry.start_at.asc())
)
# Group entries by post_id
entries_by_post = {}
for entry, post_id in entries_result:
if post_id not in entries_by_post:
entries_by_post[post_id] = []
entries_by_post[post_id].append(entry)
# Add associated_entries to each post
for post in posts:
post["associated_entries"] = entries_by_post.get(post["id"], [])
# Fetch card decoration fragments from events
card_widgets_html = {}
if post_ids:
post_slugs = [p.get("slug", "") for p in posts]
cards_html = await fetch_fragment("events", "container-cards", params={
"post_ids": ",".join(str(pid) for pid in post_ids),
"post_slugs": ",".join(post_slugs),
})
if cards_html:
card_widgets_html = _parse_card_fragments(cards_html)
tags=await client.list_tags(
limit=50000
@@ -134,4 +120,23 @@ async def posts_data(
"draft_count": draft_count,
"tag_groups": tag_groups,
"selected_groups": selected_groups,
"card_widgets_html": card_widgets_html,
}
# Regex to extract per-post blocks delimited by comment markers
_CARD_MARKER_RE = re.compile(
r'<!-- card-widget:(\d+) -->(.*?)<!-- /card-widget:\1 -->',
re.DOTALL,
)
def _parse_card_fragments(html: str) -> dict[str, str]:
"""Parse the container-cards fragment into {post_id_str: html} dict."""
result = {}
for m in _CARD_MARKER_RE.finditer(html):
post_id_str = m.group(1)
inner = m.group(2).strip()
if inner:
result[post_id_str] = inner
return result

View File

@@ -10,8 +10,8 @@ from ..ghost.ghost_sync import (
sync_single_author,
sync_single_tag,
)
from suma_browser.app.redis_cacher import clear_cache
from suma_browser.app.csrf import csrf_exempt
from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.csrf import csrf_exempt
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")

View File

@@ -1,83 +0,0 @@
"""
Internal JSON API for the coop app.
These endpoints are called by other apps (market, cart) over HTTP
to fetch Ghost CMS content and menu items without importing blog services.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.menu_item import MenuItem
from suma_browser.app.csrf import csrf_exempt
def register() -> Blueprint:
bp = Blueprint("coop_api", __name__, url_prefix="/internal")
@bp.get("/menu-items")
@csrf_exempt
async def menu_items():
"""
Return all active menu items as lightweight JSON.
Called by market and cart apps to render the nav.
"""
result = await g.s.execute(
select(MenuItem)
.where(MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
)
items = result.scalars().all()
return jsonify(
[
{
"id": mi.id,
"post": {
"title": mi.post.title if mi.post else None,
"slug": mi.post.slug if mi.post else None,
"feature_image": mi.post.feature_image if mi.post else None,
},
}
for mi in items
]
)
@bp.get("/post/<slug>")
@csrf_exempt
async def post_by_slug(slug: str):
"""
Return a Ghost post's key fields by slug.
Called by market app for the landing page.
"""
from suma_browser.app.bp.blog.ghost_db import DBClient
client = DBClient(g.s)
posts = await client.posts_by_slug(slug, include_drafts=False)
if not posts:
return jsonify(None), 404
post, original_post = posts[0]
return jsonify(
{
"post": {
"id": post.get("id"),
"title": post.get("title"),
"html": post.get("html"),
"custom_excerpt": post.get("custom_excerpt"),
"feature_image": post.get("feature_image"),
"slug": post.get("slug"),
},
"original_post": {
"id": getattr(original_post, "id", None),
"title": getattr(original_post, "title", None),
},
}
)
return bp

1
bp/fragments/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

52
bp/fragments/routes.py Normal file
View File

@@ -0,0 +1,52 @@
"""Blog app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.navigation import get_navigation_tree
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# Registry of fragment handlers: type -> async callable returning HTML str
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
# --- nav-tree fragment ---
async def _nav_tree_handler():
app_name = request.args.get("app_name", "")
path = request.args.get("path", "/")
first_seg = path.strip("/").split("/")[0]
menu_items = await get_navigation_tree(g.s)
return await render_template(
"fragments/nav_tree.html",
menu_items=menu_items,
frag_app_name=app_name,
frag_first_seg=first_seg,
)
_handlers["nav-tree"] = _nav_tree_handler
# Store handlers dict on blueprint so app code can register handlers
bp._fragment_handlers = _handlers
return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, jsonify, g
from suma_browser.app.authz import require_admin
from shared.browser.app.authz import require_admin
from .services.menu_items import (
get_all_menu_items,
get_menu_item_by_id,
@@ -12,7 +12,7 @@ from .services.menu_items import (
search_pages,
MenuItemError,
)
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')

View File

@@ -2,38 +2,32 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models.menu_item import MenuItem
from shared.models.menu_node import MenuNode
from models.ghost_content import Post
from shared.services.relationships import attach_child, detach_child
class MenuItemError(ValueError):
"""Base error for menu item service operations."""
async def get_all_menu_items(session: AsyncSession) -> list[MenuItem]:
async def get_all_menu_items(session: AsyncSession) -> list[MenuNode]:
"""
Get all menu items (excluding deleted), ordered by sort_order.
Eagerly loads the post relationship.
Get all menu nodes (excluding deleted), ordered by sort_order.
"""
from sqlalchemy.orm import selectinload
result = await session.execute(
select(MenuItem)
.where(MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
select(MenuNode)
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
.order_by(MenuNode.sort_order.asc(), MenuNode.id.asc())
)
return list(result.scalars().all())
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuItem | None:
"""Get a menu item by ID (excluding deleted)."""
from sqlalchemy.orm import selectinload
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuNode | None:
"""Get a menu node by ID (excluding deleted)."""
result = await session.execute(
select(MenuItem)
.where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
select(MenuNode)
.where(MenuNode.id == item_id, MenuNode.deleted_at.is_(None))
)
return result.scalar_one_or_none()
@@ -42,9 +36,9 @@ async def create_menu_item(
session: AsyncSession,
post_id: int,
sort_order: int | None = None
) -> MenuItem:
) -> MenuNode:
"""
Create a new menu item.
Create a MenuNode + ContainerRelation for a page.
If sort_order is not provided, adds to end of list.
"""
# Verify post exists and is a page
@@ -60,32 +54,35 @@ async def create_menu_item(
# If no sort_order provided, add to end
if sort_order is None:
max_order = await session.scalar(
select(func.max(MenuItem.sort_order))
.where(MenuItem.deleted_at.is_(None))
select(func.max(MenuNode.sort_order))
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
)
sort_order = (max_order or 0) + 1
# Check for duplicate (same post, not deleted)
# Check for duplicate (same page, not deleted)
existing = await session.scalar(
select(MenuItem).where(
MenuItem.post_id == post_id,
MenuItem.deleted_at.is_(None)
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError(f"Menu item for this page already exists.")
raise MenuItemError("Menu item for this page already exists.")
menu_item = MenuItem(
post_id=post_id,
sort_order=sort_order
menu_node = MenuNode(
container_type="page",
container_id=post_id,
label=post.title,
slug=post.slug,
feature_image=post.feature_image,
sort_order=sort_order,
)
session.add(menu_item)
session.add(menu_node)
await session.flush()
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
# Reload with post relationship
await session.refresh(menu_item, ["post"])
return menu_item
return menu_node
async def update_menu_item(
@@ -93,10 +90,10 @@ async def update_menu_item(
item_id: int,
post_id: int | None = None,
sort_order: int | None = None
) -> MenuItem:
"""Update an existing menu item."""
menu_item = await get_menu_item_by_id(session, item_id)
if not menu_item:
) -> MenuNode:
"""Update an existing menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
raise MenuItemError(f"Menu item {item_id} not found.")
if post_id is not None:
@@ -110,36 +107,45 @@ async def update_menu_item(
if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.")
# Check for duplicate (same post, different menu item)
# Check for duplicate (same page, different menu node)
existing = await session.scalar(
select(MenuItem).where(
MenuItem.post_id == post_id,
MenuItem.id != item_id,
MenuItem.deleted_at.is_(None)
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.id != item_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError(f"Menu item for this page already exists.")
raise MenuItemError("Menu item for this page already exists.")
menu_item.post_id = post_id
old_post_id = menu_node.container_id
menu_node.container_id = post_id
menu_node.label = post.title
menu_node.slug = post.slug
menu_node.feature_image = post.feature_image
if sort_order is not None:
menu_item.sort_order = sort_order
menu_node.sort_order = sort_order
await session.flush()
await session.refresh(menu_item, ["post"])
return menu_item
if post_id is not None and post_id != old_post_id:
await detach_child(session, "page", old_post_id, "menu_node", menu_node.id)
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
return menu_node
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
"""Soft delete a menu item."""
menu_item = await get_menu_item_by_id(session, item_id)
if not menu_item:
"""Soft delete a menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
return False
menu_item.deleted_at = func.now()
menu_node.deleted_at = func.now()
await session.flush()
await detach_child(session, "page", menu_node.container_id, "menu_node", menu_node.id)
return True
@@ -147,17 +153,17 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
async def reorder_menu_items(
session: AsyncSession,
item_ids: list[int]
) -> list[MenuItem]:
) -> list[MenuNode]:
"""
Reorder menu items by providing a list of IDs in desired order.
Updates sort_order for each item.
Reorder menu nodes by providing a list of IDs in desired order.
Updates sort_order for each node.
"""
items = []
for index, item_id in enumerate(item_ids):
menu_item = await get_menu_item_by_id(session, item_id)
if menu_item:
menu_item.sort_order = index
items.append(menu_item)
menu_node = await get_menu_item_by_id(session, item_id)
if menu_node:
menu_node.sort_order = index
items.append(menu_node)
await session.flush()
@@ -174,7 +180,6 @@ async def search_pages(
Search for pages (not posts) by title.
Returns (pages, total_count).
"""
# Build search filter
filters = [
Post.is_page == True, # noqa: E712
Post.status == "published",

View File

@@ -10,9 +10,9 @@ from quart import (
redirect,
url_for,
)
from suma_browser.app.authz import require_admin, require_post_author
from suma_browser.app.utils.htmx import is_htmx_request
from utils import host_url
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.utils import host_url
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@@ -21,8 +21,8 @@ def register():
@bp.get("/")
@require_admin
async def admin(slug: str):
from suma_browser.app.utils.htmx import is_htmx_request
from models.page_config import PageConfig
from shared.browser.app.utils.htmx import is_htmx_request
from shared.models.page_config import PageConfig
from sqlalchemy import select as sa_select
# Load features for page admin
@@ -33,7 +33,7 @@ def register():
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.post_id == post["id"])
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post["id"])
)).scalar_one_or_none()
if pc:
features = pc.features or {}
@@ -62,7 +62,7 @@ def register():
@require_admin
async def update_features(slug: str):
"""Update PageConfig.features for a page."""
from models.page_config import PageConfig
from shared.models.page_config import PageConfig
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from quart import jsonify
@@ -76,12 +76,14 @@ def register():
# Load or create PageConfig
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.post_id == post_id)
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(post_id=post_id, features={})
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
from shared.services.relationships import attach_child
await attach_child(g.s, "page", post_id, "page_config", pc.id)
# Parse request body
body = await request.get_json()
@@ -127,7 +129,7 @@ def register():
@require_admin
async def update_sumup(slug: str):
"""Update PageConfig SumUp credentials for a page."""
from models.page_config import PageConfig
from shared.models.page_config import PageConfig
from sqlalchemy import select as sa_select
from quart import jsonify
@@ -138,12 +140,14 @@ def register():
post_id = post["id"]
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.post_id == post_id)
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(post_id=post_id, features={})
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
from shared.services.relationships import attach_child
await attach_child(g.s, "page", post_id, "page_config", pc.id)
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
@@ -187,13 +191,12 @@ def register():
@require_admin
async def calendar_view(slug: str, calendar_id: int):
"""Show calendar month view for browsing entries"""
from models.calendars import Calendar
from shared.models.calendars import Calendar
from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks
from shared.services.registry import services
from sqlalchemy import select
from datetime import datetime, timezone
from quart import request
import calendar as pycalendar
from ...calendar.services.calendar_view import parse_int_arg, add_months, build_calendar_weeks
from ...calendar.services import get_visible_entries_for_period
from quart import session as qsession
from ..services.entry_associations import get_post_entry_ids
@@ -231,15 +234,13 @@ def register():
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
user = getattr(g, "user", None)
user_id = user.id if user else None
is_admin = bool(user and getattr(user, "is_admin", False))
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar_obj.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
month_entries = await services.calendar.visible_entries_for_period(
g.s, calendar_obj.id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
# Get associated entry IDs for this post
@@ -260,7 +261,7 @@ def register():
next_month_year=next_month_year,
prev_year=prev_year,
next_year=next_year,
month_entries=visible.merged_entries,
month_entries=month_entries,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html)
@@ -269,7 +270,7 @@ def register():
@require_admin
async def entries(slug: str):
from ..services.entry_associations import get_post_entry_ids
from models.calendars import Calendar
from shared.models.calendars import Calendar
from sqlalchemy import select
post_id = g.post_data["post"]["id"]
@@ -305,7 +306,7 @@ def register():
@require_admin
async def toggle_entry(slug: str, entry_id: int):
from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries
from models.calendars import Calendar
from shared.models.calendars import Calendar
from sqlalchemy import select
from quart import jsonify
@@ -339,7 +340,7 @@ def register():
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
@@ -366,7 +367,8 @@ def register():
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_id = g.post_data["post"]["ghost_id"]
ghost_post = await get_post_for_edit(ghost_id)
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1"
if not is_htmx_request():
@@ -388,10 +390,11 @@ def register():
@require_post_author
async def settings_save(slug: str):
from ...blog.ghost.ghost_posts import update_post_settings
from ...blog.ghost.ghost_sync import sync_single_post
from suma_browser.app.redis_cacher import invalidate_tag_cache
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
form = await request.form
updated_at = form.get("updated_at", "")
@@ -435,10 +438,14 @@ def register():
await update_post_settings(
ghost_id=ghost_id,
updated_at=updated_at,
is_page=is_page,
**kwargs,
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
@@ -452,11 +459,12 @@ def register():
@require_post_author
async def edit(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
from models.ghost_membership_entities import GhostNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from sqlalchemy import select as sa_select
ghost_id = g.post_data["post"]["ghost_id"]
ghost_post = await get_post_for_edit(ghost_id)
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1"
newsletters = (await g.s.execute(
@@ -486,10 +494,11 @@ def register():
import json
from ...blog.ghost.ghost_posts import update_post
from ...blog.ghost.lexical_validator import validate_lexical
from ...blog.ghost.ghost_sync import sync_single_post
from suma_browser.app.redis_cacher import invalidate_tag_cache
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
form = await request.form
title = form.get("title", "").strip()
lexical_raw = form.get("lexical", "")
@@ -506,7 +515,7 @@ def register():
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id)
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
@@ -517,7 +526,7 @@ def register():
ok, reason = validate_lexical(lexical_doc)
if not ok:
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id)
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
@@ -534,6 +543,7 @@ def register():
feature_image=feature_image,
custom_excerpt=custom_excerpt,
feature_image_caption=feature_image_caption,
is_page=is_page,
)
# Publish workflow
@@ -564,10 +574,14 @@ def register():
title=None,
updated_at=ghost_post["updated_at"],
status=status,
is_page=is_page,
**email_kwargs,
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
@@ -599,20 +613,14 @@ def register():
@require_admin
async def markets(slug: str):
"""List markets for this page."""
from models.market_place import MarketPlace
from sqlalchemy import select as sa_select
from shared.services.registry import services
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
page_markets = (await g.s.execute(
sa_select(MarketPlace).where(
MarketPlace.post_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
@@ -626,8 +634,7 @@ def register():
async def create_market(slug: str):
"""Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError
from models.market_place import MarketPlace
from sqlalchemy import select as sa_select
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
@@ -644,12 +651,7 @@ def register():
return jsonify({"error": str(e)}), 400
# Return updated markets list
page_markets = (await g.s.execute(
sa_select(MarketPlace).where(
MarketPlace.post_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
@@ -663,8 +665,7 @@ def register():
async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market."""
from ..services.markets import soft_delete_market
from models.market_place import MarketPlace
from sqlalchemy import select as sa_select
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
@@ -675,12 +676,7 @@ def register():
return jsonify({"error": "Market not found"}), 404
# Return updated markets list
page_markets = (await g.s.execute(
sa_select(MarketPlace).where(
MarketPlace.post_id == post_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name)
)).scalars().all()
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",

View File

@@ -8,19 +8,19 @@ from quart import (
Blueprint,
abort,
url_for,
request,
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from models.calendars import Calendar
from models.market_place import MarketPlace
from sqlalchemy import select
from shared.services.registry import services
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from suma_browser.app.redis_cacher import cache_page, clear_cache
from shared.browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from config import config
from suma_browser.app.utils.htmx import is_htmx_request
from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
@@ -64,51 +64,44 @@ def register():
async def context():
p_data = getattr(g, "post_data", None)
if p_data:
from .services.entry_associations import get_associated_entries
from shared.internal_api import get as api_get
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
db_post_id = (g.post_data.get("post") or {}).get("id")
post_slug = (g.post_data.get("post") or {}).get("slug", "")
# Fetch container nav fragments from events + market
paginate_url = url_for(
'blog.post.widget_paginate',
slug=post_slug, widget_domain='calendar',
)
).scalars().all()
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.post_id == db_post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc())
)
).scalars().all()
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, db_post_id)
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
"paginate_url": paginate_url,
}
events_nav_html, market_nav_html = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
])
container_nav_html = events_nav_html + market_nav_html
ctx = {
**p_data,
"base_title": f"{config()['title']} {p_data['post']['title']}",
"calendars": calendars,
"markets": markets,
"associated_entries": associated_entries,
"container_nav_html": container_nav_html,
}
# Page cart badge: fetch page-scoped cart count for pages
# Page cart badge via service
post_dict = p_data.get("post") or {}
if post_dict.get("is_page"):
page_cart = await api_get(
"cart",
f"/internal/cart/summary?page_slug={post_dict['slug']}",
forward_session=True,
ident = current_cart_identity()
page_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_dict["slug"],
)
if page_cart:
ctx["page_cart_count"] = page_cart.get("count", 0) + page_cart.get("calendar_count", 0)
ctx["page_cart_total"] = page_cart.get("total", 0) + page_cart.get("calendar_total", 0)
else:
ctx["page_cart_count"] = 0
ctx["page_cart_total"] = 0
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
return ctx
else:
@@ -130,7 +123,7 @@ def register():
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from utils import host_url
from shared.utils import host_url
# Get post_id from g.post_data
if not g.user:
@@ -162,24 +155,25 @@ def register():
)
return html
@bp.get("/entries/")
async def get_entries(slug: str):
"""Get paginated associated entries for infinite scroll in nav"""
from .services.entry_associations import get_associated_entries
from quart import request
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):
"""Proxies paginated widget requests to the appropriate fragment provider."""
page = int(request.args.get("page", 1))
post_id = g.post_data["post"]["id"]
result = await get_associated_entries(g.s, post_id, page=page, per_page=10)
html = await render_template(
"_types/post/_entry_items.html",
entries=result["entries"],
page=result["page"],
has_more=result["has_more"],
)
return await make_response(html)
if widget_domain == "calendar":
html = await fetch_fragment("events", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
"page": str(page),
"paginate_url": url_for(
'blog.post.widget_paginate',
slug=slug, widget_domain='calendar',
),
})
return await make_response(html or "")
abort(404)
return bp

View File

@@ -1,11 +1,8 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost, Calendar
from models.ghost_content import Post
from shared.services.registry import services
async def toggle_entry_association(
@@ -17,43 +14,14 @@ async def toggle_entry_association(
Toggle association between a post and calendar entry.
Returns (is_now_associated, error_message).
"""
# Check if entry exists (don't filter by deleted_at - allow associating with any entry)
entry = await session.scalar(
select(CalendarEntry).where(CalendarEntry.id == entry_id)
)
if not entry:
return False, f"Calendar entry {entry_id} not found in database"
# Check if post exists
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
post = await services.blog.get_post_by_id(session, post_id)
if not post:
return False, "Post not found"
# Check if association already exists
existing = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
is_associated = await services.calendar.toggle_entry_post(
session, entry_id, "post", post_id,
)
)
if existing:
# Remove association (soft delete)
existing.deleted_at = func.now()
await session.flush()
return False, None
else:
# Create association
association = CalendarEntryPost(
entry_id=entry_id,
post_id=post_id
)
session.add(association)
await session.flush()
return True, None
return is_associated, None
async def get_post_entry_ids(
@@ -64,14 +32,7 @@ async def get_post_entry_ids(
Get all entry IDs associated with this post.
Returns a set of entry IDs.
"""
result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
return set(result.scalars().all())
return await services.calendar.entry_ids_for_content(session, "post", post_id)
async def get_associated_entries(
@@ -82,58 +43,14 @@ async def get_associated_entries(
) -> dict:
"""
Get paginated associated entries for this post.
Returns dict with entries, total_count, and has_more.
Returns dict with entries (CalendarEntryDTOs), total_count, and has_more.
"""
# Get all associated entry IDs
entry_ids_result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
entries, has_more = await services.calendar.associated_entries(
session, "post", post_id, page,
)
)
entry_ids = set(entry_ids_result.scalars().all())
if not entry_ids:
return {
"entries": [],
"total_count": 0,
"has_more": False,
"page": page,
}
# Get total count
from sqlalchemy import func
total_count = len(entry_ids)
# Get paginated entries ordered by start_at desc
# Only include confirmed entries
offset = (page - 1) * per_page
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.id.in_(entry_ids),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed" # Only confirmed entries in nav
)
.order_by(CalendarEntry.start_at.desc())
.limit(per_page)
.offset(offset)
)
entries = result.scalars().all()
# Recalculate total_count based on confirmed entries only
total_count = len(entries) + offset # Rough estimate
if len(entries) < per_page:
total_count = offset + len(entries)
# Load calendar relationship for each entry
for entry in entries:
await session.refresh(entry, ["calendar"])
if entry.calendar:
await session.refresh(entry.calendar, ["post"])
has_more = len(entries) == per_page # More accurate check
total_count = len(entries) + (page - 1) * per_page
if has_more:
total_count += 1 # at least one more
return {
"entries": entries,

View File

@@ -6,10 +6,9 @@ import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.market_place import MarketPlace
from models.ghost_content import Post
from models.page_config import PageConfig
from suma_browser.app.utils import utcnow
from shared.models.page_config import PageConfig
from shared.contracts.dtos import MarketPlaceDTO
from shared.services.registry import services
class MarketError(ValueError):
@@ -29,13 +28,13 @@ def slugify(value: str, max_len: int = 255) -> str:
return value or "market"
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace:
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
name = (name or "").strip()
if not name:
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
post = await services.blog.get_post_by_id(sess, post_id)
if not post:
raise MarketError(f"Post {post_id} does not exist.")
@@ -43,46 +42,20 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Markets can only be created on pages, not posts.")
pc = (await sess.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None or not (pc.features or {}).get("market"):
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
# Look for existing (including soft-deleted)
existing = (await sess.execute(
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
)).scalar_one_or_none()
if existing:
if existing.deleted_at is not None:
existing.deleted_at = None # revive
existing.name = name
await sess.flush()
return existing
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
market = MarketPlace(post_id=post_id, name=name, slug=slug)
sess.add(market)
await sess.flush()
return market
try:
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
except ValueError as e:
raise MarketError(str(e)) from e
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
market = (
await sess.execute(
select(MarketPlace)
.join(Post, MarketPlace.post_id == Post.id)
.where(
Post.slug == post_slug,
MarketPlace.slug == market_slug,
MarketPlace.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if not market:
post = await services.blog.get_post_by_slug(sess, post_slug)
if not post:
return False
market.deleted_at = utcnow()
await sess.flush()
return True
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)

View File

@@ -4,8 +4,8 @@ from quart import Blueprint, render_template, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from suma_browser.app.authz import require_login
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from models import Snippet

84
config/app-config.yaml Normal file
View File

@@ -0,0 +1,84 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
federation: "http://localhost:8004"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

View File

@@ -13,7 +13,7 @@ fi
# Run DB migrations only if RUN_MIGRATIONS=true (blog service only)
if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then
echo "Running Alembic migrations..."
cd shared_lib && alembic upgrade head && cd /app
(cd shared && alembic upgrade head)
fi
# Clear Redis page cache on deploy

14
models/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag
# Shared models — canonical definitions live in shared/models/
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from shared.models.menu_item import MenuItem
from shared.models.kv import KV
from shared.models.magic_link import MagicLink
from shared.models.user import User

3
models/ghost_content.py Normal file
View File

@@ -0,0 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostLike,
)

View File

@@ -0,0 +1,12 @@
# Re-export from canonical shared location
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
__all__ = [
"GhostLabel", "UserLabel",
"GhostNewsletter", "UserNewsletter",
"GhostTier", "GhostSubscription",
]

4
models/kv.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.kv import KV
__all__ = ["KV"]

4
models/magic_link.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.magic_link import MagicLink
__all__ = ["MagicLink"]

4
models/menu_item.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.menu_item import MenuItem
__all__ = ["MenuItem"]

32
models/snippet.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Snippet(Base):
__tablename__ = "snippets"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
Index("ix_snippets_visibility", "visibility"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="private", server_default="private",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)

52
models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from shared.db.base import Base
class TagGroup(Base):
__tablename__ = "tag_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
colour: Mapped[Optional[str]] = mapped_column(String(32))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
tag_links: Mapped[List["TagGroupTag"]] = relationship(
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
)
class TagGroupTag(Base):
__tablename__ = "tag_group_tags"
__table_args__ = (
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_group_id: Mapped[int] = mapped_column(
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")

4
models/user.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.user import User
__all__ = ["User"]

View File

@@ -1,7 +1,9 @@
import sys
import os
# Add the shared library submodule to the Python path
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
if _shared not in sys.path:
sys.path.insert(0, _shared)
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

28
services/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""Blog app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the blog app.
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

1
shared Submodule

Submodule shared added at 9ab4b7b3fe

Submodule shared_lib deleted from 0c9b8d6aa2

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
<tr><td>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
Click the button below to sign in. This link will expire in 15&nbsp;minutes.
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
<a href="{{ link_url }}" target="_blank"
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
Sign in
</a>
</td></tr></table>
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
<p style="margin:0;font-size:12px;color:#a8a29e;">
If you did not request this email, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Hello,
Click this link to sign in:
{{ link_url }}
This link will expire in 15 minutes.
If you did not request this, you can ignore this email.

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="{{ url_for('auth.logout')|host }}" 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,7 +0,0 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('auth.newsletters'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% call links.link(cart_url('/orders/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
orders
{% endcall %}

View File

@@ -1,17 +0,0 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=un.newsletter_id) }}"
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="{{ url_for('auth.toggle_newsletter', newsletter_id=item.newsletter.id) }}"
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,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="{{ url_for('auth.login_form')|host }}"
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(url_for('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="{{ url_for('auth.start_login')|host }}"
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

@@ -69,40 +69,10 @@
{% endif %}
</a>
{# Associated Entries - Scrollable list #}
{% if post.associated_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in post.associated_entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar.slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{# Card decorations — via fragments #}
{% if card_widgets_html %}
{% set _card_html = card_widgets_html.get(post.id|string, "") %}
{% if _card_html %}{{ _card_html | safe }}{% endif %}
{% endif %}
{% include '_types/blog/_card/at_bar.html' %}

View File

@@ -1,8 +1,8 @@
{# Content type tabs: Posts | Pages #}
<div class="flex justify-center gap-1 px-3 pt-3">
{% set posts_href = (url_for('blog.home'))|host %}
{% set pages_href = (url_for('blog.home') ~ '?type=pages')|host %}
{% set posts_href = (url_for('blog.index'))|host %}
{% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %}
<a
href="{{ posts_href }}"
hx-get="{{ posts_href }}"

View File

@@ -1,5 +1,5 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% from 'macros/search.html' import search_desktop %}
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% include '_types/blog/_action_buttons.html' %}
<div
id="category-summary-desktop"

View File

@@ -8,8 +8,8 @@
The post "{{ slug }}" could not be found.
</p>
<a
href="{{ url_for('blog.home')|host }}"
hx-get="{{ url_for('blog.home')|host }}"
href="{{ url_for('blog.index')|host }}"
hx-get="{{ url_for('blog.index')|host }}"
hx-target="#main-panel"
hx-select="{{ hx_select }}"
hx-swap="outerHTML"

View File

@@ -70,7 +70,7 @@
type="text"
name="title"
value=""
placeholder="Post title..."
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"
>
@@ -101,7 +101,7 @@
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 Post</button>
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
</div>
</form>

View File

@@ -0,0 +1,19 @@
{% extends 'oob_elements.html' %}
{% 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/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
<article class="relative">
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends '_types/root/_index.html' %}
{% block meta %}
{% include '_types/post/_meta.html' %}
{% endblock %}
{% block content %}
<article class="relative">
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
{% endblock %}

View File

@@ -12,21 +12,21 @@
</div>
{# Hidden field for selected post ID - outside form for JS access #}
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.post_id if menu_item else '' }}" />
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.container_id if menu_item else '' }}" />
{# Selected page display #}
{% if menu_item %}
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
{% if menu_item.post.feature_image %}
<img src="{{ menu_item.post.feature_image }}"
alt="{{ menu_item.post.title }}"
{% if menu_item.feature_image %}
<img src="{{ menu_item.feature_image }}"
alt="{{ menu_item.label }}"
class="w-10 h-10 rounded-full object-cover" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium">{{ menu_item.post.title }}</div>
<div class="text-xs text-stone-500">{{ menu_item.post.slug }}</div>
<div class="font-medium">{{ menu_item.label }}</div>
<div class="text-xs text-stone-500">{{ menu_item.slug }}</div>
</div>
</div>
{% else %}

View File

@@ -9,9 +9,9 @@
</div>
{# Page image #}
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
@@ -19,8 +19,8 @@
{# Page title #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ item.post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.post.slug }}</div>
<div class="font-medium truncate">{{ item.label }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.slug }}</div>
</div>
{# Sort order #}
@@ -42,7 +42,7 @@
type="button"
data-confirm
data-confirm-title="Delete menu item?"
data-confirm-text="Remove {{ item.post.title }} from the menu?"
data-confirm-text="Remove {{ item.label }} from the menu?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"

View File

@@ -1,29 +1,31 @@
{% set _app_slugs = {'cart': cart_url('/')} %}
{% set _first_seg = request.path.strip('/').split('/')[0] %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="menu-items-nav-wrapper"
hx-swap-oob="outerHTML">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.post.slug, coop_url('/' + item.post.slug + '/')) %}
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
<a
href="{{ _href }}"
{% if item.post.slug not in _app_slugs %}
hx-get="/{{ item.post.slug }}/"
{% if item.slug not in _app_slugs %}
hx-get="/{{ item.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
{% endif %}
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
class="{{styles.nav_button}}"
>
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.post.title }}</span>
<span>{{ item.label }}</span>
</a>
{% endcall %}
</div>

View File

@@ -4,14 +4,14 @@
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
{% for entry in entry_list %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar.slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}"
>
{% if entry.calendar.post.feature_image %}
<img src="{{ entry.calendar.post.feature_image }}"
alt="{{ entry.calendar.post.title }}"
{% if post.feature_image %}
<img src="{{ post.feature_image }}"
alt="{{ post.title }}"
class="w-8 h-8 rounded object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>

View File

@@ -1,17 +1,6 @@
{# Main panel fragment for HTMX navigation - post article content #}
{# Main panel fragment for HTMX navigation - post/page article content #}
<article class="relative">
{# ❤️ like button - always visible in top right of article #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
{% 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 %}
{# Draft indicator + edit link #}
{# Draft indicator + edit link (shown for both posts and pages) #}
{% if post.status == "draft" %}
<div class="flex items-center justify-center gap-2 mb-3">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
@@ -36,6 +25,18 @@
</div>
{% endif %}
{% if not post.is_page %}
{# ── Blog post chrome: like button, excerpt, tags/authors ── #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
{% 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 %}
{% if post.custom_excerpt %}
<div class="w-full text-center italic text-3xl p-2">
{{post.custom_excerpt|safe}}
@@ -44,6 +45,8 @@
<div class="hidden md:block">
{% include '_types/blog/_card/at_bar.html' %}
</div>
{% endif %}
{% if post.feature_image %}
<div class="mb-3 flex justify-center">
<img

View File

@@ -1,6 +1,6 @@
{% import 'macros/links.html' as links %}
{# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #}
{% if (associated_entries and associated_entries.entries) or calendars or markets %}
{# Widget-driven container nav — entries, calendars, markets #}
{% if container_nav_widgets %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entries-calendars-nav-wrapper">
{% include '_types/post/admin/_nav_entries.html' %}
@@ -9,7 +9,6 @@
{# Admin link #}
{% if post and has_access('blog.post.admin.admin') %}
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
{% endcall %}

View File

@@ -30,7 +30,7 @@
{# Entries for this day #}
<div class="space-y-0.5">
{% for e in month_entries %}
{% if e.start_at.date() == day.date and e.deleted_at is none %}
{% if e.start_at.date() == day.date %}
{% if e.id in associated_entry_ids %}
{# Associated entry - show with delete button #}
<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">

View File

@@ -0,0 +1,50 @@
{# Left scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
<i class="fa fa-chevron-left"></i>
</button>
{# Widget-driven nav items container #}
<div id="associated-items-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
add .flex to .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
remove .flex from .entries-nav-arrow
end">
<div class="flex flex-col sm:flex-row gap-1">
{% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %}
{% include wdata.widget.template with context %}
{% endwith %}
{% endfor %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{# Right scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
<i class="fa fa-chevron-right"></i>
</button>

View File

@@ -6,7 +6,73 @@
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entries-calendars-nav-wrapper"
hx-swap-oob="true">
{% include '_types/post/admin/_nav_entries.html' %}
{# Left scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
<i class="fa fa-chevron-left"></i>
</button>
<div id="associated-items-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
add .flex to .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
remove .flex from .entries-nav-arrow
end">
<div class="flex flex-col sm:flex-row gap-1">
{# Calendar entries #}
{% if associated_entries and associated_entries.entries %}
{% for entry in associated_entries.entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}">
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600 truncate">
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
</a>
{% endfor %}
{% endif %}
{# Calendar links #}
{% if calendars %}
{% for calendar in calendars %}
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>{{calendar.name}}</div>
</a>
{% endfor %}
{% endif %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
</style>
{# Right scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
<i class="fa fa-chevron-right"></i>
</button>
</div>
{% else %}
{# Empty placeholder to remove nav items when all are disassociated/deleted #}

View File

@@ -0,0 +1,137 @@
{% macro render_scalar_table(obj) -%}
<div class="w-full overflow-x-auto sm:overflow-visible">
<table class="w-full table-fixed text-sm border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<thead class="bg-neutral-50/70 dark:bg-neutral-900/60">
<tr>
<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>
<th class="px-3 py-2 text-left font-medium">Value</th>
</tr>
</thead>
<tbody>
{% for col in obj.__mapper__.columns %}
{% set key = col.key %}
{% set val = obj|attr(key) %}
{% if key != "_sa_instance_state" %}
<tr class="border-t border-neutral-200 dark:border-neutral-800 align-top">
<td class="px-3 py-2 whitespace-nowrap text-neutral-600 dark:text-neutral-400 align-top">{{ key }}</td>
<td class="px-3 py-2 align-top">
{% if val is none %}
<span class="text-neutral-400"></span>
{% elif val.__class__.__name__ in ["datetime", "date"] and val.isoformat is defined %}
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ val.isoformat() }}</code></pre>
{% elif val is string %}
<pre class="whitespace-pre-wrap break-words break-all text-xs">{{ val }}</pre>
{% else %}
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ val }}</code></pre>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{%- endmacro %}
{% macro render_model(obj, depth=0, max_depth=2) -%}
{% if obj is none %}
<span class="text-neutral-400"></span>
{% else %}
<div class="space-y-4">
{{ render_scalar_table(obj) }}
<div class="space-y-3">
{% for rel in obj.__mapper__.relationships %}
{% set rel_name = rel.key %}
{% set loaded = rel.key in obj.__dict__ %}
{% if loaded %}
{% set value = obj|attr(rel_name) %}
{% else %}
{% set value = none %}
{% endif %}
<div class="rounded-xl border border-neutral-200 dark:border-neutral-800">
<div class="px-3 py-2 bg-neutral-50/70 dark:bg-neutral-900/60 text-sm font-medium">
Relationship: <span class="font-semibold">{{ rel_name }}</span>
<span class="ml-2 text-xs text-neutral-500">
{{ 'many' if rel.uselist else 'one' }} → {{ rel.mapper.class_.__name__ }}
{% if not loaded %} • <em>not loaded</em>{% endif %}
</span>
</div>
<div class="p-3 text-sm">
{% if value is none %}
<span class="text-neutral-400"></span>
{% elif rel.uselist %}
{% set items = value or [] %}
<div class="text-neutral-500 mb-2">{{ items|length }} item{{ '' if items|length == 1 else 's' }}</div>
{% if items %}
<div class="w-full overflow-x-auto sm:overflow-visible">
<table class="w-full table-fixed text-sm border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
<thead class="bg-neutral-50/70 dark:bg-neutral-900/60">
<tr>
<th class="px-2 py-1 text-left w-10">#</th>
<th class="px-2 py-1 text-left">Summary</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr class="border-t border-neutral-200 dark:border-neutral-800 align-top">
<td class="px-2 py-1 whitespace-nowrap align-top">{{ loop.index }}</td>
<td class="px-2 py-1 align-top">
{% set ident = [] %}
{% for k in ['id','ghost_id','uuid','slug','name','title'] if k in it.__mapper__.c %}
{% set v = (it|attr(k))|default('', true) %}
{% do ident.append(k ~ '=' ~ v) %}
{% endfor %}
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ (ident|join(' • ')) or it|string }}</code></pre>
{% if depth < max_depth %}
<div class="mt-2 pl-3 border-l border-neutral-200 dark:border-neutral-800">
{{ render_model(it, depth+1, max_depth) }}
</div>
{% else %}
<div class="mt-1 text-xs text-neutral-500">…max depth reached…</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% else %}
{% set child = value %}
{% set ident = [] %}
{% for k in ['id','ghost_id','uuid','slug','name','title'] if k in child.__mapper__.c %}
{% set v = (child|attr(k))|default('', true) %}
{% do ident.append(k ~ '=' ~ v) %}
{% endfor %}
<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{{ (ident|join(' • ')) or child|string }}</code></pre>
{% if depth < max_depth %}
<div class="pl-3 border-l border-neutral-200 dark:border-neutral-800">
{{ render_model(child, depth+1, max_depth) }}
</div>
{% else %}
<div class="text-xs text-neutral-500">…max depth reached…</div>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{%- endmacro %}
<div class="px-4 py-8">
<div class="mb-6 text-sm text-neutral-500">
Model: <code>Post</code> • Table: <code>{{ original_post.__tablename__ }}</code>
</div>
{{ render_model(original_post, 0, 2) }}
</div>

View File

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

View File

@@ -5,25 +5,24 @@
{# 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')}}
{{oob_header('post-admin-header-child', 'post_data-header-child', '_types/post_data/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% include '_types/post_data/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% include "_types/post_data/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_data-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-database" aria-hidden="true"></i>
<div>data</div>
</a>
{% call links.desktop_nav() %}
{#% include '_types/post_data/_nav.html' %#}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,24 @@
{% extends '_types/post/admin/index.html' %}
{% block ___app_title %}
{% import 'macros/links.html' as links %}
{% call links.menu_row() %}
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search) %}
<i class="fa fa-database" aria-hidden="true"></i>
<div>
data
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/post_data/_nav.html' %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/post_data/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/post_data/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,352 @@
{# ── 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-edit-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="updated_at" value="{{ ghost_post.updated_at if ghost_post else '' }}">
<input type="hidden" id="lexical-json-input" name="lexical" value="">
<input type="hidden" id="feature-image-input" name="feature_image" value="{{ ghost_post.feature_image or '' if ghost_post else '' }}">
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{{ ghost_post.feature_image_caption or '' if ghost_post else '' }}">
{# ── Feature image ── #}
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
{# Empty state: add link #}
<div
id="feature-image-empty"
class="{{ 'hidden' if ghost_post and ghost_post.feature_image else '' }}"
>
<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 {{ '' if ghost_post and ghost_post.feature_image else 'hidden' }}"
>
<img
id="feature-image-preview"
src="{{ ghost_post.feature_image or '' if ghost_post else '' }}"
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="{{ ghost_post.feature_image_caption or '' if ghost_post else '' }}"
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="{{ ghost_post.title if ghost_post else '' }}"
placeholder="{{ 'Page title...' if post and post.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"
>{{ ghost_post.custom_excerpt or '' if ghost_post else '' }}</textarea>
{# ── Editor mount point ── #}
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
{# ── Initial Lexical JSON from Ghost ── #}
<script id="lexical-initial-data" type="application/json">
{{ (ghost_post.lexical or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}')|safe }}
</script>
{# ── Status + Publish mode + Save footer ── #}
{% set already_emailed = ghost_post and ghost_post.email and ghost_post.email.status %}
<div class="flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
<select
id="status-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' if not ghost_post or ghost_post.status == 'draft' else '' }}>Draft</option>
<option value="published" {{ 'selected' if ghost_post and ghost_post.status == 'published' else '' }}>Published</option>
</select>
{# Publish mode — only relevant when publishing #}
<select
id="publish-mode-select"
name="publish_mode"
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600
{{ 'hidden' if not ghost_post or ghost_post.status != 'published' else '' }}
{{ 'opacity-50 pointer-events-none' if already_emailed else '' }}"
{{ 'disabled' if already_emailed else '' }}
>
<option value="web" selected>Web only</option>
<option value="email">Email only</option>
<option value="both">Web + Email</option>
</select>
{# Newsletter picker — only when email is involved #}
<select
id="newsletter-select"
name="newsletter_slug"
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600 hidden"
{{ 'disabled' if already_emailed else '' }}
>
<option value="">Select newsletter…</option>
{% for nl in newsletters|default([]) %}
<option value="{{ nl.slug }}">{{ nl.name }}</option>
{% endfor %}
</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"
>Save</button>
{% if save_success %}
<span class="text-[14px] text-green-600">Saved.</span>
{% endif %}
{% if request.args.get('publish_requested') %}
<span class="text-[14px] text-blue-600">Publish requested — an admin will review.</span>
{% endif %}
{% if post and 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 %}
{% if already_emailed %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
Emailed{% if ghost_post.newsletter %} to {{ ghost_post.newsletter.name }}{% endif %}
</span>
{% endif %}
</div>
{# ── Publish-mode show/hide logic ── #}
<script>
(function() {
var statusSel = document.getElementById('status-select');
var modeSel = document.getElementById('publish-mode-select');
var nlSel = document.getElementById('newsletter-select');
var alreadyEmailed = {{ 'true' if already_emailed else 'false' }};
function sync() {
var isPublished = statusSel.value === 'published';
// Show publish mode only when status is published and not already emailed
if (isPublished && !alreadyEmailed) {
modeSel.classList.remove('hidden');
} else {
modeSel.classList.add('hidden');
}
// Show newsletter picker when email is involved
var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');
if (needsEmail) {
nlSel.classList.remove('hidden');
} else {
nlSel.classList.add('hidden');
}
}
statusSel.addEventListener('change', sync);
modeSel.addEventListener('change', sync);
sync();
})();
</script>
</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, then shrink only the editor area.
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 ── */
var dataEl = document.getElementById('lexical-initial-data');
var initialJson = dataEl ? dataEl.textContent.trim() : null;
if (initialJson) {
var hidden = document.getElementById('lexical-json-input');
if (hidden) hidden.value = initialJson;
}
window.mountEditor('lexical-editor', {
initialJson: initialJson,
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-edit-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

@@ -0,0 +1,5 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
settings
{% endcall %}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,198 @@
{# ── Post/Page Settings Form ── #}
{% set gp = ghost_post or {} %}
{% set _is_page = post.is_page if post else False %}
{% macro field_label(text, field_for=None) %}
<label {% if field_for %}for="{{ field_for }}"{% endif %}
class="block text-[13px] font-medium text-stone-500 mb-[4px]">{{ text }}</label>
{% endmacro %}
{% macro text_input(name, value='', placeholder='', type='text', maxlength=None) %}
<input
type="{{ type }}"
name="{{ name }}"
id="settings-{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
bg-white text-stone-700 placeholder:text-stone-300
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
>
{% endmacro %}
{% macro textarea_input(name, value='', placeholder='', rows=3, maxlength=None) %}
<textarea
name="{{ name }}"
id="settings-{{ name }}"
rows="{{ rows }}"
placeholder="{{ placeholder }}"
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
bg-white text-stone-700 placeholder:text-stone-300 resize-y
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
>{{ value }}</textarea>
{% endmacro %}
{% macro checkbox_input(name, checked=False, label='') %}
<label class="inline-flex items-center gap-[8px] cursor-pointer">
<input
type="checkbox"
name="{{ name }}"
id="settings-{{ name }}"
{{ 'checked' if checked else '' }}
class="rounded border-stone-300 text-stone-600 focus:ring-stone-300"
>
<span class="text-[14px] text-stone-600">{{ label }}</span>
</label>
{% endmacro %}
{% macro section(title, open=False) %}
<details class="border border-stone-200 rounded-[8px] overflow-hidden" {{ 'open' if open else '' }}>
<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer
select-none hover:bg-stone-100 transition-colors">
{{ title }}
</summary>
<div class="px-[16px] py-[12px] space-y-[12px]">
{{ caller() }}
</div>
</details>
{% endmacro %}
<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="updated_at" value="{{ gp.updated_at or '' }}">
<div class="space-y-[12px] mt-[16px]">
{# ── General ── #}
{% call section('General', open=True) %}
<div>
{{ field_label('Slug', 'settings-slug') }}
{{ text_input('slug', gp.slug or '', 'page-slug' if _is_page else 'post-slug') }}
</div>
<div>
{{ field_label('Published at', 'settings-published_at') }}
<input
type="datetime-local"
name="published_at"
id="settings-published_at"
value="{{ gp.published_at[:16] if gp.published_at else '' }}"
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
bg-white text-stone-700
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
>
</div>
<div>
{{ checkbox_input('featured', gp.featured, 'Featured page' if _is_page else 'Featured post') }}
</div>
<div>
{{ field_label('Visibility', 'settings-visibility') }}
<select
name="visibility"
id="settings-visibility"
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
bg-white text-stone-700
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
>
<option value="public" {{ 'selected' if (gp.visibility or 'public') == 'public' }}>Public</option>
<option value="members" {{ 'selected' if gp.visibility == 'members' }}>Members</option>
<option value="paid" {{ 'selected' if gp.visibility == 'paid' }}>Paid</option>
</select>
</div>
<div>
{{ checkbox_input('email_only', gp.email_only, 'Email only') }}
</div>
{% endcall %}
{# ── Tags ── #}
{% call section('Tags') %}
<div>
{{ field_label('Tags (comma-separated)', 'settings-tags') }}
{% set tag_names = gp.tags|map(attribute='name')|list|join(', ') if gp.tags else '' %}
{{ text_input('tags', tag_names, 'news, updates, featured') }}
<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p>
</div>
{% endcall %}
{# ── Feature Image ── #}
{% call section('Feature Image') %}
<div>
{{ field_label('Alt text', 'settings-feature_image_alt') }}
{{ text_input('feature_image_alt', gp.feature_image_alt or '', 'Describe the feature image') }}
</div>
{% endcall %}
{# ── SEO / Meta ── #}
{% call section('SEO / Meta') %}
<div>
{{ field_label('Meta title', 'settings-meta_title') }}
{{ text_input('meta_title', gp.meta_title or '', 'SEO title', maxlength=300) }}
<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p>
</div>
<div>
{{ field_label('Meta description', 'settings-meta_description') }}
{{ textarea_input('meta_description', gp.meta_description or '', 'SEO description', rows=2, maxlength=500) }}
<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p>
</div>
<div>
{{ field_label('Canonical URL', 'settings-canonical_url') }}
{{ text_input('canonical_url', gp.canonical_url or '', 'https://example.com/original-post', type='url') }}
</div>
{% endcall %}
{# ── Facebook / OpenGraph ── #}
{% call section('Facebook / OpenGraph') %}
<div>
{{ field_label('OG title', 'settings-og_title') }}
{{ text_input('og_title', gp.og_title or '') }}
</div>
<div>
{{ field_label('OG description', 'settings-og_description') }}
{{ textarea_input('og_description', gp.og_description or '', rows=2) }}
</div>
<div>
{{ field_label('OG image URL', 'settings-og_image') }}
{{ text_input('og_image', gp.og_image or '', 'https://...', type='url') }}
</div>
{% endcall %}
{# ── X / Twitter ── #}
{% call section('X / Twitter') %}
<div>
{{ field_label('Twitter title', 'settings-twitter_title') }}
{{ text_input('twitter_title', gp.twitter_title or '') }}
</div>
<div>
{{ field_label('Twitter description', 'settings-twitter_description') }}
{{ textarea_input('twitter_description', gp.twitter_description or '', rows=2) }}
</div>
<div>
{{ field_label('Twitter image URL', 'settings-twitter_image') }}
{{ text_input('twitter_image', gp.twitter_image or '', 'https://...', type='url') }}
</div>
{% endcall %}
{# ── Advanced ── #}
{% call section('Advanced') %}
<div>
{{ field_label('Custom template', 'settings-custom_template') }}
{{ text_input('custom_template', gp.custom_template or '', 'custom-page.hbs' if _is_page else 'custom-post.hbs') }}
</div>
{% endcall %}
</div>
{# ── Save footer ── #}
<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">
<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"
>Save settings</button>
{% if save_success %}
<span class="text-[14px] text-green-600">Saved.</span>
{% endif %}
</div>
</form>

View File

@@ -0,0 +1,5 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
edit
{% endcall %}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
{% set select_colours = "
[.hover-capable_&]:hover:bg-yellow-300
aria-selected:bg-stone-500 aria-selected:text-white
[.hover-capable_&[aria-selected=true]:hover]:bg-orange-500
"%}
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='root-row', oob=oob) %}
<div class="w-full flex flex-row items-top">
{# Cart mini — fetched from cart app as fragment #}
{% if cart_mini_html %}
{{ cart_mini_html | safe }}
{% endif %}
{# Site title #}
<div class="font-bold text-5xl flex-1">
{% from 'macros/title.html' import title with context %}
{{ title('flex justify-center md:justify-start')}}
</div>
{# Desktop nav #}
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
{% if nav_tree_html %}
{{ nav_tree_html | safe }}
{% endif %}
{# Auth menu — fetched from account app as fragment #}
{% if auth_menu_html %}
{{ auth_menu_html | safe }}
{% endif %}
{% include "_types/root/_nav_panel.html"%}
</nav>
{% include '_types/root/_hamburger.html' %}
</div>
{% endcall %}
{# Mobile user info #}
<div class="block md:hidden">
{% if auth_menu_html %}
{{ auth_menu_html | safe }}
{% endif %}
</div>
{% endmacro %}

View File

@@ -0,0 +1,32 @@
{# Nav-tree fragment — rendered by blog, consumed by all apps.
Uses frag_app_name / frag_first_seg instead of request.path / app_name
so the consuming app's context is reflected correctly.
No hx-boost — cross-app nav links are full page navigations. #}
{% set _app_slugs = {
'cart': cart_url('/'),
'market': market_url('/'),
'events': events_url('/'),
'federation': federation_url('/'),
'account': account_url('/'),
} %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="menu-items-nav-wrapper">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
<a
href="{{ _href }}"
aria-selected="{{ 'true' if (item.slug == frag_first_seg or item.slug == frag_app_name) else 'false' }}"
class="{{styles.nav_button_less_pad}}"
>
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.label }}</span>
</a>
{% endcall %}
</div>

View File

@@ -0,0 +1,21 @@
{#
Shared admin navigation macro
Use this instead of duplicate _nav.html files
#}
{% macro admin_nav_item(href, icon='cog', label='', select_colours='', aclass=styles.nav_button) %}
{% import 'macros/links.html' as links %}
{% call links.link(href, hx_select_search, select_colours, True, aclass=aclass) %}
<i class="fa fa-{{ icon }}" aria-hidden="true"></i>
{{ label }}
{% endcall %}
{% endmacro %}
{% macro placeholder_nav() %}
{# Placeholder for admin sections without specific nav items #}
<div class="relative nav-group">
<span class="block px-3 py-2 text-stone-400 text-sm italic">
Admin options
</span>
</div>
{% endmacro %}

View File

@@ -0,0 +1,68 @@
{#
Scrolling menu macro with arrow navigation
Creates a horizontally scrollable menu (desktop) or vertically scrollable (mobile)
with arrow buttons that appear/hide based on content overflow.
Parameters:
- container_id: Unique ID for the scroll container
- items: List of items to iterate over
- item_content: Caller block that renders each item (receives 'item' variable)
- wrapper_class: Optional additional classes for outer wrapper
- container_class: Optional additional classes for scroll container
- item_class: Optional additional classes for each item wrapper
#}
{% macro scrolling_menu(container_id, items, wrapper_class='', container_class='', item_class='') %}
{% if items %}
{# Left scroll arrow - desktop only #}
<button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
<i class="fa fa-chevron-left"></i>
</button>
{# Scrollable container #}
<div id="{{ container_id }}"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .scrolling-menu-arrow-{{ container_id }}
add .flex to .scrolling-menu-arrow-{{ container_id }}
else
add .hidden to .scrolling-menu-arrow-{{ container_id }}
remove .flex from .scrolling-menu-arrow-{{ container_id }}
end">
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
{% for item in items %}
<div class="{{ item_class }}">
{{ caller(item) }}
</div>
{% endfor %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{# Right scroll arrow - desktop only #}
<button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
<i class="fa fa-chevron-right"></i>
</button>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,24 @@
{% macro sticker(src, title, enabled, size=40, found=false) -%}
<span class="relative inline-flex items-center justify-center group"
tabindex="0" aria-label="{{ title|capitalize }}">
<!-- sticker icon -->
<img
src="{{ src }}"
width="{{size}}" height="{{size}}"
alt="{{ title|capitalize }}"
title="{{ title|capitalize }}"
class="{% if found %}border-2 border-yellow-200 bg-yellow-300{% endif %} {%if enabled %} opacity-100 {% else %} opacity-40 saturate-0 {% endif %}"
/>
<!-- tooltip -->
<span role="tooltip"
class="pointer-events-none absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover/tt:block group-focus-visible/tt:block whitespace-nowrap rounded-md bg-stone-900 text-white text-xs px-2 py-1 shadow-lg">
{{ title|capitalize if title|lower != 'sugarfree' else 'Sugar' }}
<!-- little arrow -->
<span class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-stone-900"></span>
</span>
</span>
{%- endmacro -%}