Compare commits
108 Commits
relations
...
c2fe142039
| Author | SHA1 | Date | |
|---|---|---|---|
| c2fe142039 | |||
| f0fbcef3f6 | |||
| d7f9afff8e | |||
| f2910ad767 | |||
| e75c8d16d1 | |||
| 984e2ebed0 | |||
| d80894dbf5 | |||
| 8e16cc459a | |||
| 336a4ad9a1 | |||
| d6f3250a77 | |||
| 486ab834de | |||
| 41e803335a | |||
| 1f36987f77 | |||
| e53e8cc1f7 | |||
| 418ac9424f | |||
| fb8f115acb | |||
| 63b895afd8 | |||
| 50b33ab08e | |||
| bd314a0be7 | |||
| 41cdd6eab8 | |||
| 1a6503782d | |||
| 72997068c6 | |||
| dacb61b0ae | |||
| 400667b15a | |||
| 44503a7d9b | |||
| e085fe43b4 | |||
| 0554f8a113 | |||
| 4e5f9ff16c | |||
| 193578ef88 | |||
| 03f0929fdf | |||
| f551fc7453 | |||
| e30cb0a992 | |||
| 293f7713d6 | |||
| 4ba63bda17 | |||
| 0a81a2af01 | |||
| 0c9dbd6657 | |||
| a4377668be | |||
| a98354c0f0 | |||
| df8b19ccb8 | |||
| 544892edd9 | |||
| c243d17eeb | |||
| 5b4cacaf19 | |||
| a8c0741f54 | |||
| 0af07f9f2e | |||
| 222738546a | |||
| 4098c32878 | |||
| 3bd4f4b661 | |||
| 5dd1161816 | |||
| 002cc49f2c | |||
| e6b0849ce3 | |||
| 8024fa5b13 | |||
| ea18a402d6 | |||
| e4e43177a8 | |||
| 8445c36270 | |||
| 5578923242 | |||
| 9754b892d6 | |||
| ab75e505a8 | |||
| 13bcf755f6 | |||
| 3d55145e5f | |||
| 8b2785ccb0 | |||
| 03196c3ad0 | |||
| 815c5285d5 | |||
| ed30f88f05 | |||
| 8aedbc9e62 | |||
| 8ceb9aee62 | |||
| 4668c30890 | |||
| 39f61eddd6 | |||
| 5436dfe76c | |||
| 4ede0368dc | |||
| a8e06e87fb | |||
| 588d240ddc | |||
| aa5c251a45 | |||
| 7ccb463a8b | |||
| 341fc4cf28 | |||
| 1a5969202e | |||
| 3bc5de126d | |||
| 1447122a0c | |||
| ab45e21c7c | |||
| c0d369eb8e | |||
| 755313bd29 | |||
| 01a67029f0 | |||
| b54f7b4b56 | |||
| 5ede32e21c | |||
| 7aea1f1be9 | |||
| 0ef4a93a92 | |||
| 48696498ef | |||
| b7d95a8b4e | |||
| e7d5c6734b | |||
| e4a6d2dfc8 | |||
| 0a5562243b | |||
| 2b41aaa6ce | |||
| cfe66e5342 | |||
| 382d1b7c7a | |||
| a580a53328 | |||
| 0f9af31ffe | |||
| e8bc228c7f | |||
| 17cebe07e7 | |||
| 82b411f25a | |||
| a643b3532d | |||
| 22802bd36b | |||
| 0d48fd22ee | |||
| b92e7a763e | |||
| fec5ecdfb1 | |||
| 269bcc02be | |||
| 9f2f0dacaf | |||
| 39e013a75e | |||
| 2df1014ee3 | |||
| e8a991834b |
177
.claude/plans/glittery-discovering-kahn.md
Normal file
177
.claude/plans/glittery-discovering-kahn.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Sexp Fragment Protocol: Component Defs Between Services
|
||||
|
||||
## Context
|
||||
|
||||
Fragment endpoints return raw sexp source (e.g., `(~blog-nav-wrapper :items ...)`). The consuming service embeds this in its page sexp, which the client evaluates. But blog-specific components like `~blog-nav-wrapper` are only in blog's `_COMPONENT_ENV` — not in market's. So market's `client_components_tag()` never sends them to the client, causing "Unknown component" errors.
|
||||
|
||||
The fix: transfer component definitions alongside fragments. Services tell the provider what they already have; the provider sends only what's missing. The consuming service registers received defs into its `_COMPONENT_ENV` so they're included in `client_components_tag()` output for the client.
|
||||
|
||||
## Approach: Structured Sexp Request/Response
|
||||
|
||||
Replace the current GET + `X-Fragment-Request` header protocol with POST + sexp body. This aligns with the vision in `docs/sexpr-internal-protocol-first.md`.
|
||||
|
||||
### Request format (POST body)
|
||||
```scheme
|
||||
(fragment-request
|
||||
:type "nav-tree"
|
||||
:params (:app-name "market" :path "/")
|
||||
:components (~blog-nav-wrapper ~blog-nav-item-link ~header-row-sx ...))
|
||||
```
|
||||
|
||||
`:components` lists component names already in the consumer's `_COMPONENT_ENV`. Provider skips these.
|
||||
|
||||
### Response format
|
||||
```scheme
|
||||
(fragment-response
|
||||
:defs ((defcomp ~blog-nav-wrapper (&key ...) ...) (defcomp ~blog-nav-item-link ...))
|
||||
:content (~blog-nav-wrapper :items ...))
|
||||
```
|
||||
|
||||
`:defs` contains only components the consumer doesn't have. `:content` is the fragment sexp (same as current response body).
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `shared/infrastructure/fragments.py` — Client side
|
||||
|
||||
**`fetch_fragment()`**: Switch from GET to POST with sexp body.
|
||||
|
||||
- Build request body using `sexp_call`:
|
||||
```python
|
||||
from shared.sexp.helpers import sexp_call, SexpExpr
|
||||
from shared.sexp.jinja_bridge import _COMPONENT_ENV
|
||||
|
||||
comp_names = [k for k in _COMPONENT_ENV if k.startswith("~")]
|
||||
body = sexp_call("fragment-request",
|
||||
type=fragment_type,
|
||||
params=params or {},
|
||||
components=SexpExpr("(" + " ".join(comp_names) + ")"))
|
||||
```
|
||||
- POST to same URL, body as `text/sexp`, keep `X-Fragment-Request` header for backward compat
|
||||
- Parse response: extract `:defs` and `:content` from the sexp response
|
||||
- Register defs into `_COMPONENT_ENV` via `register_components()`
|
||||
- Return `:content` wrapped as `SexpExpr`
|
||||
|
||||
**New helper `_parse_fragment_response(text)`**:
|
||||
- `parse()` the response sexp
|
||||
- Extract keyword args (reuse the keyword-extraction pattern from `evaluator.py`)
|
||||
- Return `(defs_source, content_source)` tuple
|
||||
|
||||
### 2. `shared/sexp/helpers.py` — Response builder
|
||||
|
||||
**New `fragment_response(content, request_text)`**:
|
||||
|
||||
```python
|
||||
def fragment_response(content: str, request_text: str) -> str:
|
||||
"""Build a structured fragment response with missing component defs."""
|
||||
from .parser import parse, serialize
|
||||
from .types import Keyword, Component
|
||||
from .jinja_bridge import _COMPONENT_ENV
|
||||
|
||||
# Parse request to get :components list
|
||||
req = parse(request_text)
|
||||
loaded = set()
|
||||
# extract :components keyword value
|
||||
...
|
||||
|
||||
# Diff against _COMPONENT_ENV, serialize missing defs
|
||||
defs_parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if not isinstance(val, Component):
|
||||
continue
|
||||
if key in loaded or f"~{val.name}" in loaded:
|
||||
continue
|
||||
defs_parts.append(_serialize_defcomp(val))
|
||||
|
||||
defs_sexp = "(" + " ".join(defs_parts) + ")" if defs_parts else "nil"
|
||||
return sexp_call("fragment-response",
|
||||
defs=SexpExpr(defs_sexp),
|
||||
content=SexpExpr(content))
|
||||
```
|
||||
|
||||
### 3. Fragment endpoints — All services
|
||||
|
||||
**Generic change in each `bp/fragments/routes.py`**: Update the route handler to accept POST, read sexp body, use `fragment_response()` for the response.
|
||||
|
||||
The `get_fragment` handler becomes:
|
||||
```python
|
||||
@bp.route("/<fragment_type>", methods=["GET", "POST"])
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/sexp")
|
||||
content = await handler()
|
||||
|
||||
# Structured sexp protocol (POST with sexp body)
|
||||
request_body = await request.get_data(as_text=True)
|
||||
if request_body and request.content_type == "text/sexp":
|
||||
from shared.sexp.helpers import fragment_response
|
||||
body = fragment_response(content, request_body)
|
||||
return Response(body, status=200, content_type="text/sexp")
|
||||
|
||||
# Legacy GET fallback
|
||||
return Response(content, status=200, content_type="text/sexp")
|
||||
```
|
||||
|
||||
Since all fragment endpoints follow the identical `_handlers` + `get_fragment` pattern, we can extract this into a shared helper in `fragments.py` or a new `shared/infrastructure/fragment_endpoint.py`.
|
||||
|
||||
### 4. Extract shared fragment endpoint helper
|
||||
|
||||
To avoid touching every service's fragment routes, create a shared blueprint factory:
|
||||
|
||||
**`shared/infrastructure/fragment_endpoint.py`**:
|
||||
```python
|
||||
def create_fragment_blueprint(handlers: dict) -> Blueprint:
|
||||
"""Create a fragment endpoint blueprint with sexp protocol support."""
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.route("/<fragment_type>", methods=["GET", "POST"])
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/sexp")
|
||||
content = await handler()
|
||||
|
||||
# Sexp protocol: POST with structured request/response
|
||||
if request.method == "POST" and request.content_type == "text/sexp":
|
||||
request_body = await request.get_data(as_text=True)
|
||||
from shared.sexp.helpers import fragment_response
|
||||
body = fragment_response(content, request_body)
|
||||
return Response(body, status=200, content_type="text/sexp")
|
||||
|
||||
return Response(content, status=200, content_type="text/sexp")
|
||||
|
||||
return bp
|
||||
```
|
||||
|
||||
Then each service's `register()` just returns `create_fragment_blueprint(_handlers)`. This is a small refactor since they all duplicate the same boilerplate today.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `shared/infrastructure/fragments.py` | POST sexp body, parse response, register defs |
|
||||
| `shared/sexp/helpers.py` | `fragment_response()` builder, `_serialize_defcomp()` |
|
||||
| `shared/infrastructure/fragment_endpoint.py` | **New** — shared blueprint factory |
|
||||
| `blog/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `market/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `events/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `cart/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `account/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `orders/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `federation/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
| `relations/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
|
||||
|
||||
## Verification
|
||||
|
||||
1. Start blog + market services: `./dev.sh blog market`
|
||||
2. Load market page — should fetch nav-tree from blog with sexp protocol
|
||||
3. Check market logs: no "Unknown component" errors
|
||||
4. Inspect page source: `client_components_tag()` output includes `~blog-nav-wrapper` etc.
|
||||
5. Cross-domain sx-get navigation (blog → market) works without reload
|
||||
6. Run sexp tests: `python3 -m pytest shared/sexp/tests/ -x -q`
|
||||
7. Second page load: `:components` list in request includes blog nav components, response `:defs` is empty
|
||||
325
.claude/plans/hazy-sniffing-sphinx.md
Normal file
325
.claude/plans/hazy-sniffing-sphinx.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Split Cart into Microservices
|
||||
|
||||
## Context
|
||||
The cart app currently owns too much: CartItem, Order/OrderItem, PageConfig, ContainerRelation, plus all checkout/payment logic. We're splitting it into 4 pieces:
|
||||
|
||||
1. **Relations service** — internal only, owns ContainerRelation
|
||||
2. **Likes service** — internal only, unified generic likes replacing ProductLike + PostLike
|
||||
3. **PageConfig → blog** — move to blog (which already owns pages)
|
||||
4. **Orders service** — public (orders.rose-ash.com), owns Order/OrderItem + SumUp checkout
|
||||
|
||||
After the split, cart becomes a thin CartItem CRUD + inbox service.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Relations Service (internal only)
|
||||
|
||||
### 1.1 Scaffold `relations/`
|
||||
Create minimal internal-only app (no templates, no context_fn):
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `relations/__init__.py` | Empty |
|
||||
| `relations/path_setup.py` | Copy from cart |
|
||||
| `relations/app.py` | `create_base_app("relations")`, register data + actions BPs only |
|
||||
| `relations/services/__init__.py` | Empty `register_domain_services()` |
|
||||
| `relations/models/__init__.py` | `from shared.models.container_relation import ContainerRelation` |
|
||||
| `relations/bp/__init__.py` | Export `register_data`, `register_actions` |
|
||||
| `relations/bp/data/routes.py` | Move `get-children` handler from `cart/bp/data/routes.py:175-198` |
|
||||
| `relations/bp/actions/routes.py` | Move `attach-child` + `detach-child` from `cart/bp/actions/routes.py:112-153` |
|
||||
| `relations/alembic.ini` | Copy from cart, adjust path |
|
||||
| `relations/alembic/env.py` | MODELS=`["shared.models.container_relation"]`, TABLES=`{"container_relations"}` |
|
||||
| `relations/alembic/versions/0001_initial.py` | Create `container_relations` table |
|
||||
| `relations/Dockerfile` | Follow cart pattern, `COPY relations/ ./` |
|
||||
| `relations/entrypoint.sh` | Standard pattern, db=`db_relations` |
|
||||
|
||||
### 1.2 Retarget callers (`"cart"` → `"relations"`)
|
||||
|
||||
| File | Lines | Change |
|
||||
|------|-------|--------|
|
||||
| `events/bp/calendars/services/calendars.py` | 74, 111, 121 | `call_action("cart", ...)` → `call_action("relations", ...)` |
|
||||
| `blog/bp/menu_items/services/menu_items.py` | 83, 137, 141, 157 | Same |
|
||||
| `shared/services/market_impl.py` | 96, 109, 133 | Same |
|
||||
|
||||
### 1.3 Clean up cart
|
||||
- Remove `get-children` from `cart/bp/data/routes.py:175-198`
|
||||
- Remove `attach-child`, `detach-child` from `cart/bp/actions/routes.py:112-153`
|
||||
- Remove `"shared.models.container_relation"` and `"container_relations"` from `cart/alembic/env.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Likes Service (internal only)
|
||||
|
||||
### 2.1 New unified model
|
||||
Single `likes` table in `db_likes`:
|
||||
|
||||
```python
|
||||
class Like(Base):
|
||||
__tablename__ = "likes"
|
||||
id: Mapped[int] (pk)
|
||||
user_id: Mapped[int] (not null, indexed)
|
||||
target_type: Mapped[str] (String 32, not null) # "product" or "post"
|
||||
target_slug: Mapped[str | None] (String 255) # for products
|
||||
target_id: Mapped[int | None] (Integer) # for posts
|
||||
created_at, updated_at, deleted_at
|
||||
|
||||
UniqueConstraint("user_id", "target_type", "target_slug")
|
||||
UniqueConstraint("user_id", "target_type", "target_id")
|
||||
Index("ix_likes_target", "target_type", "target_slug")
|
||||
```
|
||||
|
||||
Products use `target_type="product"`, `target_slug=slug`. Posts use `target_type="post"`, `target_id=post.id`.
|
||||
|
||||
### 2.2 Scaffold `likes/`
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `likes/__init__.py` | Empty |
|
||||
| `likes/path_setup.py` | Standard |
|
||||
| `likes/app.py` | Internal-only, `create_base_app("likes")`, data + actions BPs |
|
||||
| `likes/services/__init__.py` | Empty `register_domain_services()` |
|
||||
| `likes/models/__init__.py` | Import Like |
|
||||
| `likes/models/like.py` | Generic Like model (above) |
|
||||
| `likes/bp/__init__.py` | Export register functions |
|
||||
| `likes/bp/data/routes.py` | `is-liked`, `liked-slugs`, `liked-ids` |
|
||||
| `likes/bp/actions/routes.py` | `toggle` action |
|
||||
| `likes/alembic.ini` | Standard |
|
||||
| `likes/alembic/env.py` | MODELS=`["likes.models.like"]`, TABLES=`{"likes"}` |
|
||||
| `likes/alembic/versions/0001_initial.py` | Create `likes` table |
|
||||
| `likes/Dockerfile` | Standard pattern |
|
||||
| `likes/entrypoint.sh` | Standard, db=`db_likes` |
|
||||
|
||||
### 2.3 Data endpoints (`likes/bp/data/routes.py`)
|
||||
- `is-liked`: params `user_id, target_type, target_slug/target_id` → `{"liked": bool}`
|
||||
- `liked-slugs`: params `user_id, target_type` → `["slug1", "slug2"]`
|
||||
- `liked-ids`: params `user_id, target_type` → `[1, 2, 3]`
|
||||
|
||||
### 2.4 Action endpoints (`likes/bp/actions/routes.py`)
|
||||
- `toggle`: payload `{user_id, target_type, target_slug?, target_id?}` → `{"liked": bool}`
|
||||
|
||||
### 2.5 Retarget market app
|
||||
|
||||
**`market/bp/product/routes.py`** (like_toggle, ~line 119):
|
||||
Replace `toggle_product_like(g.s, user_id, product_slug)` with:
|
||||
```python
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": product_slug
|
||||
})
|
||||
liked = result["liked"]
|
||||
```
|
||||
|
||||
**`market/bp/browse/services/db_backend.py`** (most complex):
|
||||
- `db_product_full` / `db_product_full_id`: Replace `ProductLike` subquery with `fetch_data("likes", "is-liked", ...)`. Annotate `is_liked` after query.
|
||||
- `db_products_nocounts` / `db_products_counts`: Fetch `liked_slugs` once via `fetch_data("likes", "liked-slugs", ...)`, filter `Product.slug.in_(liked_slugs)` for `?liked=true`, annotate `is_liked` post-query.
|
||||
|
||||
**Delete**: `toggle_product_like` from `market/bp/product/services/product_operations.py`
|
||||
|
||||
### 2.6 Retarget blog app
|
||||
|
||||
**`blog/bp/post/routes.py`** (like_toggle):
|
||||
Replace `toggle_post_like(g.s, user_id, post_id)` with `call_action("likes", "toggle", payload={...})`.
|
||||
|
||||
**Delete**: `toggle_post_like` from `blog/bp/post/services/post_operations.py`
|
||||
|
||||
### 2.7 Remove old like models
|
||||
- Remove `ProductLike` from `shared/models/market.py` (lines 118-131) + `Product.likes` relationship (lines 110-114)
|
||||
- Remove `PostLike` from `shared/models/ghost_content.py` + `Post.likes` relationship
|
||||
- Remove `product_likes` from market alembic TABLES
|
||||
- Remove `post_likes` from blog alembic TABLES
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: PageConfig → Blog
|
||||
|
||||
### 3.1 Replace blog proxy endpoints with direct DB queries
|
||||
|
||||
**`blog/bp/data/routes.py`** (lines 77-102): Replace the 3 proxy handlers that currently call `fetch_data("cart", ...)` with direct DB queries. Copy logic from `cart/bp/data/routes.py`:
|
||||
- `page-config` (cart lines 114-134)
|
||||
- `page-config-by-id` (cart lines 136-149)
|
||||
- `page-configs-batch` (cart lines 151-172)
|
||||
- `page-config-ensure` (cart lines 49-81) — add new
|
||||
|
||||
Also add the `_page_config_dict` helper (cart lines 203-213).
|
||||
|
||||
### 3.2 Move action to blog
|
||||
|
||||
**`blog/bp/actions/routes.py`** (~line 40): Replace `call_action("cart", "update-page-config", ...)` proxy with direct handler. Copy logic from `cart/bp/actions/routes.py:51-110`.
|
||||
|
||||
### 3.3 Blog callers become local
|
||||
|
||||
| File | Current | After |
|
||||
|------|---------|-------|
|
||||
| `blog/bp/post/admin/routes.py:34` | `fetch_data("cart", "page-config", ...)` | Direct DB query (blog now owns table) |
|
||||
| `blog/bp/post/admin/routes.py:87,132` | `call_action("cart", "update-page-config", ...)` | Direct call to local handler |
|
||||
| `blog/bp/post/services/markets.py:44` | `fetch_data("cart", "page-config", ...)` | Direct DB query |
|
||||
| `blog/bp/blog/ghost_db.py:295` | `fetch_data("cart", "page-configs-batch", ...)` | Direct DB query |
|
||||
|
||||
### 3.4 Retarget cross-service callers (`"cart"` → `"blog"`)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `cart/bp/cart/services/page_cart.py:181` | `fetch_data("cart", "page-configs-batch", ...)` → `fetch_data("blog", "page-configs-batch", ...)` |
|
||||
| `cart/bp/cart/global_routes.py:274` | `fetch_data("cart", "page-config-by-id", ...)` → `fetch_data("blog", "page-config-by-id", ...)` |
|
||||
|
||||
(Note: `checkout.py:117` and `cart/app.py:177` already target `"blog"`)
|
||||
|
||||
### 3.5 Update blog alembic
|
||||
**`blog/alembic/env.py`**: Add `"shared.models.page_config"` to MODELS and `"page_configs"` to TABLES.
|
||||
|
||||
### 3.6 Clean up cart
|
||||
- Remove all `page-config*` handlers from `cart/bp/data/routes.py` (lines 49-172)
|
||||
- Remove `update-page-config` from `cart/bp/actions/routes.py` (lines 50-110)
|
||||
- Remove `"shared.models.page_config"` and `"page_configs"` from `cart/alembic/env.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Orders Service (public, orders.rose-ash.com)
|
||||
|
||||
### 4.1 Scaffold `orders/`
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| `orders/__init__.py` | Empty |
|
||||
| `orders/path_setup.py` | Standard |
|
||||
| `orders/app.py` | Public app with `context_fn`, templates, fragments, page slug hydration |
|
||||
| `orders/services/__init__.py` | `register_domain_services()` |
|
||||
| `orders/models/__init__.py` | `from shared.models.order import Order, OrderItem` |
|
||||
| `orders/bp/__init__.py` | Export all BPs |
|
||||
| `orders/bp/order/` | Move from `cart/bp/order/` (single order: detail, pay, recheck) |
|
||||
| `orders/bp/orders/` | Move from `cart/bp/orders/` (order list + pagination) |
|
||||
| `orders/bp/checkout/routes.py` | Webhook + return routes from `cart/bp/cart/global_routes.py` |
|
||||
| `orders/bp/data/routes.py` | Minimal |
|
||||
| `orders/bp/actions/routes.py` | `create-order` action (called by cart during checkout) |
|
||||
| `orders/bp/fragments/routes.py` | `account-nav-item` fragment (orders link) |
|
||||
| `orders/templates/` | Move `_types/order/`, `_types/orders/`, checkout templates from cart |
|
||||
| `orders/alembic.ini` | Standard |
|
||||
| `orders/alembic/env.py` | MODELS=`["shared.models.order"]`, TABLES=`{"orders", "order_items"}` |
|
||||
| `orders/alembic/versions/0001_initial.py` | Create `orders` + `order_items` tables |
|
||||
| `orders/Dockerfile` | Standard, public-facing |
|
||||
| `orders/entrypoint.sh` | Standard, db=`db_orders` |
|
||||
|
||||
### 4.2 Move checkout services to orders
|
||||
|
||||
**Move to `orders/services/`:**
|
||||
- `checkout.py` — from `cart/bp/cart/services/checkout.py` (move: `create_order_from_cart`, `resolve_page_config`, `build_sumup_*`, `get_order_with_details`. Keep `find_or_create_cart_item` in cart.)
|
||||
- `check_sumup_status.py` — from `cart/bp/cart/services/check_sumup_status.py`
|
||||
|
||||
**`clear_cart_for_order`** stays in cart as new action:
|
||||
- Add `clear-cart-for-order` to `cart/bp/actions/routes.py`
|
||||
- Orders calls `call_action("cart", "clear-cart-for-order", payload={user_id, session_id, page_post_id})`
|
||||
|
||||
### 4.3 `create-order` action endpoint (`orders/bp/actions/routes.py`)
|
||||
Cart's `POST /checkout/` calls this:
|
||||
```
|
||||
Payload: {cart_items: [{product_id, product_title, product_slug, product_image,
|
||||
product_special_price, product_regular_price, product_price_currency,
|
||||
quantity, market_place_container_id}],
|
||||
calendar_entries, tickets, user_id, session_id,
|
||||
product_total, calendar_total, ticket_total,
|
||||
page_post_id, redirect_url, webhook_base_url}
|
||||
Returns: {order_id, sumup_hosted_url, page_config_id, sumup_reference, description}
|
||||
```
|
||||
|
||||
### 4.4 Refactor cart's checkout route
|
||||
`cart/bp/cart/global_routes.py` `POST /checkout/`:
|
||||
1. Load local cart data (get_cart, calendar entries, tickets, totals)
|
||||
2. Serialize cart items to dicts
|
||||
3. `result = await call_action("orders", "create-order", payload={...})`
|
||||
4. Redirect to `result["sumup_hosted_url"]`
|
||||
|
||||
Same for page-scoped checkout in `cart/bp/cart/page_routes.py`.
|
||||
|
||||
### 4.5 Move webhook + return routes to orders
|
||||
- `POST /checkout/webhook/<order_id>/` → `orders/bp/checkout/routes.py`
|
||||
- `GET /checkout/return/<order_id>/` → `orders/bp/checkout/routes.py`
|
||||
- SumUp redirect/webhook URLs must now point to orders.rose-ash.com
|
||||
|
||||
### 4.6 Move order list/detail routes
|
||||
- `cart/bp/order/` → `orders/bp/order/`
|
||||
- `cart/bp/orders/` → `orders/bp/orders/`
|
||||
|
||||
### 4.7 Move startup reconciliation
|
||||
`_reconcile_pending_orders` from `cart/app.py:209-265` → `orders/app.py`
|
||||
|
||||
### 4.8 Clean up cart
|
||||
- Remove `cart/bp/order/`, `cart/bp/orders/`
|
||||
- Remove checkout webhook/return from `cart/bp/cart/global_routes.py`
|
||||
- Remove `_reconcile_pending_orders` from `cart/app.py`
|
||||
- Remove order templates from `cart/templates/`
|
||||
- Remove `"shared.models.order"` and `"orders", "order_items"` from `cart/alembic/env.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Infrastructure (applies to all new services)
|
||||
|
||||
### 5.1 docker-compose.yml
|
||||
Add 3 new services (relations, likes, orders) with own DATABASE_URL (db_relations, db_likes, db_orders), own REDIS_URL (Redis DB 7, 8, 9).
|
||||
|
||||
Add to `x-app-env`:
|
||||
```yaml
|
||||
INTERNAL_URL_RELATIONS: http://relations:8000
|
||||
INTERNAL_URL_LIKES: http://likes:8000
|
||||
INTERNAL_URL_ORDERS: http://orders:8000
|
||||
APP_URL_ORDERS: https://orders.rose-ash.com
|
||||
```
|
||||
|
||||
### 5.2 docker-compose.dev.yml
|
||||
Add all 3 services with dev volumes (ports 8008, 8009, 8010).
|
||||
Add to `x-sibling-models` for all 3 new services.
|
||||
|
||||
### 5.3 deploy.sh
|
||||
Add `relations likes orders` to APPS list.
|
||||
|
||||
### 5.4 Caddyfile (`/root/caddy/Caddyfile`)
|
||||
Add only orders (public):
|
||||
```
|
||||
orders.rose-ash.com { reverse_proxy rose-ash-dev-orders-1:8000 }
|
||||
```
|
||||
|
||||
### 5.5 shared/infrastructure/factory.py
|
||||
Add to model import loop: `"relations.models", "likes.models", "orders.models"`
|
||||
|
||||
### 5.6 shared/infrastructure/urls.py
|
||||
Add `orders_url(path)` helper.
|
||||
|
||||
### 5.7 All existing Dockerfiles
|
||||
Add sibling model COPY lines for the 3 new services to every existing Dockerfile (blog, market, cart, events, federation, account).
|
||||
|
||||
### 5.8 CLAUDE.md
|
||||
Update project structure and add notes about the new services.
|
||||
|
||||
---
|
||||
|
||||
## Data Migration (one-time, run before code switch)
|
||||
|
||||
1. `container_relations` from `db_cart` → `db_relations`
|
||||
2. `product_likes` from `db_market` + `post_likes` from `db_blog` → `db_likes.likes`
|
||||
3. `page_configs` from `db_cart` → `db_blog`
|
||||
4. `orders` + `order_items` from `db_cart` → `db_orders`
|
||||
|
||||
Use `pg_dump`/`pg_restore` or direct SQL for migration.
|
||||
|
||||
---
|
||||
|
||||
## Post-Split Cart State
|
||||
|
||||
After all 4 phases, cart owns only:
|
||||
- **Model**: CartItem (table in db_cart)
|
||||
- **Alembic**: `cart_items` only
|
||||
- **Data endpoints**: `cart-summary`, `cart-items`
|
||||
- **Action endpoints**: `adopt-cart-for-user`, `clear-cart-for-order` (new)
|
||||
- **Inbox handlers**: Add/Remove/Update `rose:CartItem`
|
||||
- **Public routes**: cart overview, page cart, add-to-cart, quantity, delete
|
||||
- **Fragments**: `cart-mini`
|
||||
- **Checkout**: POST /checkout/ (creates order via `call_action("orders", "create-order")`, redirects to SumUp)
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
1. **Relations**: Blog attach/detach marketplace to page; events attach/detach calendar
|
||||
2. **Likes**: Toggle product like on market page; toggle post like on blog; `?liked=true` filter
|
||||
3. **PageConfig**: Blog admin page config update; cart checkout resolves page config from blog
|
||||
4. **Orders**: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com
|
||||
5. No remaining `call_action("cart", "attach-child|detach-child|update-page-config")`
|
||||
6. No remaining `fetch_data("cart", "page-config*|get-children")`
|
||||
7. Cart alembic only manages `cart_items` table
|
||||
@@ -58,13 +58,22 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
for app in blog market cart events federation account relations likes orders; do
|
||||
# Map compose service name to source directory
|
||||
app_dir() {
|
||||
case \"\$1\" in
|
||||
sx_docs) echo \"sx\" ;;
|
||||
*) echo \"\$1\" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for app in blog market cart events federation account relations likes orders test sx_docs; do
|
||||
dir=\$(app_dir \"\$app\")
|
||||
IMAGE_EXISTS=\$(docker image ls -q ${{ env.REGISTRY }}/\$app:latest 2>/dev/null)
|
||||
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
|
||||
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$dir/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
|
||||
echo \"Building \$app...\"
|
||||
docker build \
|
||||
--build-arg CACHEBUST=\$(date +%s) \
|
||||
-f \$app/Dockerfile \
|
||||
-f \$dir/Dockerfile \
|
||||
-t ${{ env.REGISTRY }}/\$app:latest \
|
||||
-t ${{ env.REGISTRY }}/\$app:${{ github.sha }} \
|
||||
.
|
||||
|
||||
@@ -16,6 +16,9 @@ app_urls:
|
||||
events: "https://events.rose-ash.com"
|
||||
federation: "https://federation.rose-ash.com"
|
||||
account: "https://account.rose-ash.com"
|
||||
sx: "https://sx.rose-ash.com"
|
||||
test: "https://test.rose-ash.com"
|
||||
orders: "https://orders.rose-ash.com"
|
||||
cache:
|
||||
fs_root: /app/_snapshot # <- absolute path to your snapshot dir
|
||||
categories:
|
||||
|
||||
4
account/actions.sx
Normal file
4
account/actions.sx
Normal file
@@ -0,0 +1,4 @@
|
||||
;; Account service — inter-service action endpoints
|
||||
;;
|
||||
;; ghost-sync-member and ghost-push-member use local service imports —
|
||||
;; remain as Python fallbacks.
|
||||
43
account/alembic/versions/0003_add_user_profile_fields.py
Normal file
43
account/alembic/versions/0003_add_user_profile_fields.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Add author profile fields to users table.
|
||||
|
||||
Merges Ghost Author profile data into User — bio, profile_image, cover_image,
|
||||
website, location, facebook, twitter, slug, is_admin.
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002_hash_oauth_tokens
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "acct_0003"
|
||||
down_revision = "acct_0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("users", sa.Column("slug", sa.String(191), nullable=True))
|
||||
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("profile_image", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("cover_image", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("website", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("location", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("facebook", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column("twitter", sa.Text(), nullable=True))
|
||||
op.add_column("users", sa.Column(
|
||||
"is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false"),
|
||||
))
|
||||
op.create_index("ix_users_slug", "users", ["slug"], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_users_slug")
|
||||
op.drop_column("users", "is_admin")
|
||||
op.drop_column("users", "twitter")
|
||||
op.drop_column("users", "facebook")
|
||||
op.drop_column("users", "location")
|
||||
op.drop_column("users", "website")
|
||||
op.drop_column("users", "cover_image")
|
||||
op.drop_column("users", "profile_image")
|
||||
op.drop_column("users", "bio")
|
||||
op.drop_column("users", "slug")
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
@@ -8,7 +7,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_account_bp, register_auth_bp, register_fragments
|
||||
from bp import register_account_bp, register_auth_bp
|
||||
|
||||
|
||||
async def account_context() -> dict:
|
||||
@@ -44,14 +43,14 @@ async def account_context() -> dict:
|
||||
if ident["session_id"] is not None:
|
||||
cart_params["session_id"] = ident["session_id"]
|
||||
|
||||
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
|
||||
cart_mini, auth_menu, nav_tree = await fetch_fragments([
|
||||
("cart", "cart-mini", cart_params or None),
|
||||
("account", "auth-menu", {"email": user.email} if user else None),
|
||||
("blog", "nav-tree", {"app_name": "account", "path": request.path}),
|
||||
])
|
||||
ctx["cart_mini_html"] = cart_mini_html
|
||||
ctx["auth_menu_html"] = auth_menu_html
|
||||
ctx["nav_tree_html"] = nav_tree_html
|
||||
ctx["cart_mini"] = cart_mini
|
||||
ctx["auth_menu"] = auth_menu
|
||||
ctx["nav_tree"] = nav_tree
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -72,10 +71,23 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Load .sx component files and setup defpage routes
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
|
||||
from sxc.pages import setup_account_pages
|
||||
setup_account_pages()
|
||||
|
||||
# --- blueprints ---
|
||||
app.register_blueprint(register_auth_bp())
|
||||
app.register_blueprint(register_account_bp())
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
account_bp = register_account_bp()
|
||||
app.register_blueprint(account_bp)
|
||||
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "account")
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "account")
|
||||
|
||||
from bp.actions.routes import register as register_actions
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .account.routes import register as register_account_bp
|
||||
from .auth.routes import register as register_auth_bp
|
||||
from .fragments import register_fragments
|
||||
|
||||
@@ -1,103 +1,33 @@
|
||||
"""Account pages blueprint.
|
||||
|
||||
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
|
||||
Mounted at root /.
|
||||
Mounted at root /. GET page handlers replaced by defpage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
make_response,
|
||||
redirect,
|
||||
g,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
from shared.infrastructure.urls import login_url
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
|
||||
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",
|
||||
}
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
from shared.sx.helpers import sx_response, render_to_sx
|
||||
|
||||
|
||||
def register(url_prefix="/"):
|
||||
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
|
||||
|
||||
@account_bp.context_processor
|
||||
async def context():
|
||||
@account_bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Fetch account_nav fragments for layout."""
|
||||
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
||||
("events", "account-nav-item", {}),
|
||||
("cart", "account-nav-item", {}),
|
||||
("artdag", "nav-item", {}),
|
||||
], required=False)
|
||||
return {"oob": oob, "account_nav_html": events_nav + cart_nav + artdag_nav}
|
||||
|
||||
@account_bp.get("/")
|
||||
async def account():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_account_page, render_account_oob
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/"))
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_account_page(ctx)
|
||||
else:
|
||||
html = await render_account_oob(ctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@account_bp.get("/newsletters/")
|
||||
async def newsletters():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/newsletters/"))
|
||||
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
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()}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_newsletters_page, render_newsletters_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_newsletters_page(ctx, newsletter_list)
|
||||
else:
|
||||
html = await render_newsletters_oob(ctx, newsletter_list)
|
||||
|
||||
return await make_response(html)
|
||||
g.account_nav = events_nav + cart_nav + artdag_nav
|
||||
|
||||
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
||||
async def toggle_newsletter(newsletter_id: int):
|
||||
@@ -124,34 +54,26 @@ def register(url_prefix="/"):
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
from sexp.sexp_components import render_newsletter_toggle
|
||||
return render_newsletter_toggle(un)
|
||||
# Render toggle directly — no sx_components intermediary
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.infrastructure.urls import account_url
|
||||
|
||||
# Catch-all for fragment-provided pages — must be last
|
||||
@account_bp.get("/<slug>/")
|
||||
async def fragment_page(slug):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from quart import abort
|
||||
nid = un.newsletter_id
|
||||
url_fn = getattr(g, "_account_url", None) or account_url
|
||||
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
|
||||
csrf = generate_csrf_token()
|
||||
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
|
||||
translate = "translate-x-6" if un.subscribed else "translate-x-1"
|
||||
checked = "true" if un.subscribed else "false"
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url(f"/{slug}/"))
|
||||
|
||||
fragment_html = await fetch_fragment(
|
||||
"events", "account-page",
|
||||
params={"slug": slug, "user_id": str(g.user.id)},
|
||||
)
|
||||
if not fragment_html:
|
||||
abort(404)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_fragment_page, render_fragment_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_fragment_page(ctx, fragment_html)
|
||||
else:
|
||||
html = await render_fragment_oob(ctx, fragment_html)
|
||||
|
||||
return await make_response(html)
|
||||
return sx_response(await render_to_sx(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"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}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
))
|
||||
|
||||
return account_bp
|
||||
|
||||
@@ -1,63 +1,33 @@
|
||||
"""Account app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers (blog webhooks) via the internal action client.
|
||||
All actions remain as Python fallbacks (local service imports).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
bp, _handlers = create_action_blueprint("account")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
try:
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Action %s failed", action_name)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
# --- ghost-sync-member ---
|
||||
async def _ghost_sync_member():
|
||||
"""Sync a single Ghost member into db_account."""
|
||||
data = await request.get_json()
|
||||
ghost_id = data.get("ghost_id")
|
||||
if not ghost_id:
|
||||
return {"error": "ghost_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_single_member
|
||||
await sync_single_member(g.s, ghost_id)
|
||||
return {"ok": True}
|
||||
|
||||
_handlers["ghost-sync-member"] = _ghost_sync_member
|
||||
|
||||
# --- ghost-push-member ---
|
||||
async def _ghost_push_member():
|
||||
"""Push a local user's membership data to Ghost."""
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return {"error": "user_id required"}, 400
|
||||
|
||||
from services.ghost_membership import sync_member_to_ghost
|
||||
result_id = await sync_member_to_ghost(g.s, int(user_id))
|
||||
return {"ok": True, "ghost_id": result_id}
|
||||
|
||||
@@ -44,7 +44,18 @@ from .services import (
|
||||
SESSION_USER_KEY = "uid"
|
||||
ACCOUNT_SESSION_KEY = "account_sid"
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "artdag", "artdag_l2"}
|
||||
|
||||
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
|
||||
"""Render an auth page with root layout — replaces sx_components helpers."""
|
||||
from shared.sx.helpers import render_to_sx, full_page_sx, root_header_sx
|
||||
from shared.sx.page import get_template_context
|
||||
ctx = await get_template_context()
|
||||
hdr = await root_header_sx(ctx)
|
||||
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
|
||||
return await full_page_sx(ctx, header_rows=hdr, content=content,
|
||||
meta_html=f"<title>{title}</title>")
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
|
||||
|
||||
|
||||
def register(url_prefix="/auth"):
|
||||
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
|
||||
redirect_url = pop_login_redirect_target()
|
||||
return redirect(redirect_url)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context()
|
||||
return await render_login_page(ctx)
|
||||
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
|
||||
|
||||
@rate_limit(
|
||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
|
||||
|
||||
is_valid, email = validate_email(email_input)
|
||||
if not is_valid:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Please enter a valid email address.", email=email_input,
|
||||
), 400
|
||||
|
||||
# Per-email rate limit: 5 magic links per 15 minutes
|
||||
from shared.infrastructure.rate_limit import _check_rate_limit
|
||||
try:
|
||||
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
|
||||
if not allowed:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=None)
|
||||
return await render_check_email_page(ctx), 200
|
||||
return await _render_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email,
|
||||
), 200
|
||||
except Exception:
|
||||
pass # Redis down — allow the request
|
||||
|
||||
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
|
||||
"Please try again in a moment."
|
||||
)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_check_email_page
|
||||
ctx = await get_template_context(email=email, email_error=email_error)
|
||||
return await render_check_email_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-check-email-content", "Check your email \u2014 Rose Ash",
|
||||
email=email, email_error=email_error,
|
||||
)
|
||||
|
||||
@auth_bp.get("/magic/<token>/")
|
||||
async def magic(token: str):
|
||||
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
|
||||
user, error = await validate_magic_link(s, token)
|
||||
|
||||
if error:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context(error=error)
|
||||
return await render_login_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error=error,
|
||||
), 400
|
||||
user_id = user.id
|
||||
|
||||
except Exception:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_login_page
|
||||
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
|
||||
return await render_login_page(ctx), 502
|
||||
return await _render_auth_page(
|
||||
"account-login-content", "Login \u2014 Rose Ash",
|
||||
error="Could not sign you in right now. Please try again.",
|
||||
), 502
|
||||
|
||||
assert user_id is not None
|
||||
|
||||
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
|
||||
@auth_bp.get("/device/")
|
||||
async def device_form():
|
||||
"""Browser form where user enters the code displayed in terminal."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
code = request.args.get("code", "")
|
||||
ctx = await get_template_context(code=code)
|
||||
return await render_device_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
code=code,
|
||||
)
|
||||
|
||||
@auth_bp.post("/device")
|
||||
@auth_bp.post("/device/")
|
||||
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
|
||||
user_code = (form.get("code") or "").strip().replace("-", "").upper()
|
||||
|
||||
if not user_code or len(user_code) != 8:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Please enter a valid 8-character code.", code=form.get("code", ""),
|
||||
), 400
|
||||
|
||||
from shared.infrastructure.auth_redis import get_auth_redis
|
||||
|
||||
r = await get_auth_redis()
|
||||
device_code = await r.get(f"devflow_uc:{user_code}")
|
||||
if not device_code:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code not found or expired. Please try again.", code=form.get("code", ""),
|
||||
), 400
|
||||
|
||||
if isinstance(device_code, bytes):
|
||||
device_code = device_code.decode()
|
||||
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
|
||||
# Logged in — approve immediately
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page
|
||||
ctx = await get_template_context(error="Code expired or already used.")
|
||||
return await render_device_page(ctx), 400
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code expired or already used.",
|
||||
), 400
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_approved_page
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||
)
|
||||
|
||||
@auth_bp.get("/device/complete")
|
||||
@auth_bp.get("/device/complete/")
|
||||
async def device_complete():
|
||||
"""Post-login redirect — completes approval after magic link auth."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_device_page, render_device_approved_page
|
||||
|
||||
device_code = request.args.get("code", "")
|
||||
|
||||
if not device_code:
|
||||
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
|
||||
|
||||
ok = await _approve_device(device_code, g.user)
|
||||
if not ok:
|
||||
ctx = await get_template_context(
|
||||
return await _render_auth_page(
|
||||
"account-device-content", "Authorize Device \u2014 Rose Ash",
|
||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||
)
|
||||
return await render_device_page(ctx), 400
|
||||
), 400
|
||||
|
||||
ctx = await get_template_context()
|
||||
return await render_device_approved_page(ctx)
|
||||
return await _render_auth_page(
|
||||
"account-device-approved", "Device Authorized \u2014 Rose Ash",
|
||||
)
|
||||
|
||||
return auth_bp
|
||||
|
||||
@@ -1,67 +1,14 @@
|
||||
"""Account app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``account/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from sqlalchemy import select
|
||||
from shared.models import User
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- user-by-email ---
|
||||
async def _user_by_email():
|
||||
"""Return user_id for a given email address."""
|
||||
email = request.args.get("email", "").strip().lower()
|
||||
if not email:
|
||||
return None
|
||||
result = await g.s.execute(
|
||||
select(User.id).where(User.email.ilike(email))
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
return {"user_id": row[0]}
|
||||
|
||||
_handlers["user-by-email"] = _user_by_email
|
||||
|
||||
# --- newsletters ---
|
||||
async def _newsletters():
|
||||
"""Return all Ghost newsletters (for blog post editor)."""
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
|
||||
.order_by(GhostNewsletter.name)
|
||||
)
|
||||
return [
|
||||
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
_handlers["newsletters"] = _newsletters
|
||||
|
||||
bp, _handlers = create_data_blueprint("account")
|
||||
return bp
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Account app fragment endpoints.
|
||||
|
||||
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
Fragments:
|
||||
auth-menu Desktop + mobile auth menu (sign-in or user link)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Fragment handlers
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def _auth_menu():
|
||||
from shared.infrastructure.urls import account_url
|
||||
from shared.sexp.jinja_bridge import sexp as render_sexp
|
||||
|
||||
user_email = request.args.get("email", "")
|
||||
return render_sexp(
|
||||
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||
**{"user-email": user_email or None, "account-url": account_url("")},
|
||||
)
|
||||
|
||||
_handlers = {
|
||||
"auth-menu": _auth_menu,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Routing
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@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")
|
||||
|
||||
return bp
|
||||
@@ -54,6 +54,7 @@ fi
|
||||
RELOAD_FLAG=""
|
||||
if [[ "${RELOAD:-}" == "true" ]]; then
|
||||
RELOAD_FLAG="--reload"
|
||||
python3 -m shared.dev_watcher &
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
|
||||
else
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
|
||||
|
||||
9
account/queries.sx
Normal file
9
account/queries.sx
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Account service — inter-service data queries
|
||||
|
||||
(defquery user-by-email (&key email)
|
||||
"Return user_id for a given email address."
|
||||
(service "account" "user-by-email" :email email))
|
||||
|
||||
(defquery newsletters ()
|
||||
"Return all Ghost newsletters."
|
||||
(service "account" "newsletters"))
|
||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the account app.
|
||||
"""Register services for the account app."""
|
||||
from shared.services.registry import services
|
||||
from .account_page import AccountPageService
|
||||
services.register("account_page", AccountPageService())
|
||||
|
||||
Account is a consumer-only dashboard app. It has no own domain.
|
||||
All cross-app data comes via fragments and HTTP data endpoints.
|
||||
"""
|
||||
pass
|
||||
from shared.services.account_impl import SqlAccountDataService
|
||||
services.register("account", SqlAccountDataService())
|
||||
|
||||
40
account/services/account_page.py
Normal file
40
account/services/account_page.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Account page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountPageService:
|
||||
"""Service for account page data, callable via (service "account-page" ...)."""
|
||||
|
||||
async def newsletters_data(self, session, **kw):
|
||||
"""Return newsletter list with user subscription status."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
|
||||
result = await session.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
sub_result = await session.execute(
|
||||
select(UserNewsletter).where(
|
||||
UserNewsletter.user_id == g.user.id,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
|
||||
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
from shared.infrastructure.urls import account_url
|
||||
return {
|
||||
"newsletter_list": newsletter_list,
|
||||
"account_url": account_url(""),
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
;; Auth page components (login, device, check email)
|
||||
|
||||
(defcomp ~account-login-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-login-form (&key error-html action csrf-token email)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
||||
(raw! error-html)
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
|
||||
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
|
||||
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Send magic link"))))
|
||||
|
||||
(defcomp ~account-device-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-device-form (&key error-html action csrf-token code)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
|
||||
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
|
||||
(raw! error-html)
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "code" :class "block text-sm font-medium mb-1" "Device code")
|
||||
(input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"
|
||||
:required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"
|
||||
:class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Authorize"))))
|
||||
|
||||
(defcomp ~account-device-approved ()
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||
|
||||
(defcomp ~account-check-email-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
|
||||
(raw! error))))
|
||||
|
||||
(defcomp ~account-check-email (&key email error-html)
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")
|
||||
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
|
||||
(raw! error-html)))
|
||||
@@ -1,37 +0,0 @@
|
||||
;; Newsletter management components
|
||||
|
||||
(defcomp ~account-newsletter-desc (&key description)
|
||||
(when description
|
||||
(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description))))
|
||||
|
||||
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
|
||||
(div :id id :class "flex items-center"
|
||||
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
|
||||
:class cls :role "switch" :aria-checked checked
|
||||
(span :class knob-cls))))
|
||||
|
||||
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
|
||||
(div :id id :class "flex items-center"
|
||||
(button :hx-post url :hx-headers hdrs :hx-target target :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"))))
|
||||
|
||||
(defcomp ~account-newsletter-item (&key name desc-html toggle-html)
|
||||
(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" (raw! name))
|
||||
(raw! desc-html))
|
||||
(div :class "ml-4 flex-shrink-0" (raw! toggle-html))))
|
||||
|
||||
(defcomp ~account-newsletter-list (&key items-html)
|
||||
(div :class "divide-y divide-stone-100" (raw! items-html)))
|
||||
|
||||
(defcomp ~account-newsletter-empty ()
|
||||
(p :class "text-sm text-stone-500" "No newsletters available."))
|
||||
|
||||
(defcomp ~account-newsletters-panel (&key list-html)
|
||||
(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")
|
||||
(raw! list-html))))
|
||||
@@ -1,385 +0,0 @@
|
||||
"""
|
||||
Account service s-expression page components.
|
||||
|
||||
Renders account dashboard, newsletters, fragment pages, login, and device
|
||||
auth pages. Called from route handlers in place of ``render_template()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, root_header_html, search_desktop_html,
|
||||
search_mobile_html, full_page, oob_page,
|
||||
)
|
||||
|
||||
# Load account-specific .sexpr components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_nav_html(ctx: dict) -> str:
|
||||
"""Auth section desktop nav items."""
|
||||
html = render(
|
||||
"nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
account_nav_html = ctx.get("account_nav_html", "")
|
||||
if account_nav_html:
|
||||
html += account_nav_html
|
||||
return html
|
||||
|
||||
|
||||
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the account section header row."""
|
||||
return render(
|
||||
"menu-row",
|
||||
id="auth-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "account_url", "/"),
|
||||
link_label="account", icon="fa-solid fa-user",
|
||||
nav_html=_auth_nav_html(ctx),
|
||||
child_id="auth-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _auth_nav_mobile_html(ctx: dict) -> str:
|
||||
"""Mobile nav menu for auth section."""
|
||||
html = render(
|
||||
"nav-link",
|
||||
href=call_url(ctx, "account_url", "/newsletters/"),
|
||||
label="newsletters",
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
)
|
||||
account_nav_html = ctx.get("account_nav_html", "")
|
||||
if account_nav_html:
|
||||
html += account_nav_html
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account dashboard (GET /)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _account_main_panel_html(ctx: dict) -> str:
|
||||
"""Account info panel with user details and logout."""
|
||||
from quart import g
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
error = ctx.get("error", "")
|
||||
|
||||
error_html = render("account-error-banner", error=error) if error else ""
|
||||
|
||||
user_email_html = ""
|
||||
user_name_html = ""
|
||||
if user:
|
||||
user_email_html = render("account-user-email", email=user.email)
|
||||
if user.name:
|
||||
user_name_html = render("account-user-name", name=user.name)
|
||||
|
||||
logout_html = render("account-logout-form", csrf_token=generate_csrf_token())
|
||||
|
||||
labels_html = ""
|
||||
if user and hasattr(user, "labels") and user.labels:
|
||||
label_items = "".join(
|
||||
render("account-label-item", name=label.name)
|
||||
for label in user.labels
|
||||
)
|
||||
labels_html = render("account-labels-section", items_html=label_items)
|
||||
|
||||
return render(
|
||||
"account-main-panel",
|
||||
error_html=error_html, email_html=user_email_html,
|
||||
name_html=user_name_html, logout_html=logout_html,
|
||||
labels_html=labels_html,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Newsletters (GET /newsletters/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
||||
"""Render a single newsletter toggle switch."""
|
||||
nid = un.newsletter_id
|
||||
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||
if un.subscribed:
|
||||
bg = "bg-emerald-500"
|
||||
translate = "translate-x-6"
|
||||
checked = "true"
|
||||
else:
|
||||
bg = "bg-stone-300"
|
||||
translate = "translate-x-1"
|
||||
checked = "false"
|
||||
return render(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"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}",
|
||||
checked=checked,
|
||||
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
|
||||
)
|
||||
|
||||
|
||||
def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str:
|
||||
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
||||
return render(
|
||||
"account-newsletter-toggle-off",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||
target=f"#nl-{nid}",
|
||||
)
|
||||
|
||||
|
||||
def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Newsletters management panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
account_url_fn = ctx.get("account_url") or (lambda p: p)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
if newsletter_list:
|
||||
items = []
|
||||
for item in newsletter_list:
|
||||
nl = item["newsletter"]
|
||||
un = item.get("un")
|
||||
|
||||
desc_html = render(
|
||||
"account-newsletter-desc", description=nl.description
|
||||
) if nl.description else ""
|
||||
|
||||
if un:
|
||||
toggle = _newsletter_toggle_html(un, account_url_fn, csrf)
|
||||
else:
|
||||
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
||||
toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
|
||||
|
||||
items.append(render(
|
||||
"account-newsletter-item",
|
||||
name=nl.name, desc_html=desc_html, toggle_html=toggle,
|
||||
))
|
||||
list_html = render(
|
||||
"account-newsletter-list",
|
||||
items_html="".join(items),
|
||||
)
|
||||
else:
|
||||
list_html = render("account-newsletter-empty")
|
||||
|
||||
return render("account-newsletters-panel", list_html=list_html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth pages (login, device, check_email)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _login_page_content(ctx: dict) -> str:
|
||||
"""Login form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
email = ctx.get("email", "")
|
||||
action = url_for("auth.start_login")
|
||||
|
||||
error_html = render("account-login-error", error=error) if error else ""
|
||||
|
||||
return render(
|
||||
"account-login-form",
|
||||
error_html=error_html, action=action,
|
||||
csrf_token=generate_csrf_token(), email=email,
|
||||
)
|
||||
|
||||
|
||||
def _device_page_content(ctx: dict) -> str:
|
||||
"""Device authorization form content."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for
|
||||
|
||||
error = ctx.get("error", "")
|
||||
code = ctx.get("code", "")
|
||||
action = url_for("auth.device_submit")
|
||||
|
||||
error_html = render("account-device-error", error=error) if error else ""
|
||||
|
||||
return render(
|
||||
"account-device-form",
|
||||
error_html=error_html, action=action,
|
||||
csrf_token=generate_csrf_token(), code=code,
|
||||
)
|
||||
|
||||
|
||||
def _device_approved_content() -> str:
|
||||
"""Device approved success content."""
|
||||
return render("account-device-approved")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Account dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_account_page(ctx: dict) -> str:
|
||||
"""Full page: account dashboard."""
|
||||
main = _account_main_panel_html(ctx)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_account_oob(ctx: dict) -> str:
|
||||
"""OOB response for account dashboard."""
|
||||
main = _account_main_panel_html(ctx)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Newsletters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Full page: newsletters."""
|
||||
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
||||
"""OOB response for newsletters."""
|
||||
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=main,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""Full page: fragment-provided content."""
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=page_fragment_html,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
||||
"""OOB response for fragment pages."""
|
||||
oobs = (
|
||||
_auth_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
|
||||
return oob_page(ctx, oobs_html=oobs,
|
||||
content_html=page_fragment_html,
|
||||
menu_html=_auth_nav_mobile_html(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Auth pages (login, device)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_login_page(ctx: dict) -> str:
|
||||
"""Full page: login form."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_login_page_content(ctx),
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_page(ctx: dict) -> str:
|
||||
"""Full page: device authorization form."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_device_page_content(ctx),
|
||||
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
async def render_device_approved_page(ctx: dict) -> str:
|
||||
"""Full page: device approved."""
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_device_approved_content(),
|
||||
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Check email page (POST /start/ success)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_email_content(email: str, email_error: str | None = None) -> str:
|
||||
"""Check email confirmation content."""
|
||||
from markupsafe import escape
|
||||
|
||||
error_html = render(
|
||||
"account-check-email-error", error=str(escape(email_error))
|
||||
) if email_error else ""
|
||||
|
||||
return render(
|
||||
"account-check-email",
|
||||
email=str(escape(email)), error_html=error_html,
|
||||
)
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
"""Full page: check email after magic link sent."""
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr,
|
||||
content_html=_check_email_content(email, email_error),
|
||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment renderers for POST handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_newsletter_toggle_html(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
|
||||
generate_csrf_token())
|
||||
|
||||
|
||||
def render_newsletter_toggle(un) -> str:
|
||||
"""Render a newsletter toggle switch for POST response (uses account_url)."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import g
|
||||
account_url_fn = getattr(g, "_account_url", None)
|
||||
if account_url_fn is None:
|
||||
from shared.infrastructure.urls import account_url
|
||||
account_url_fn = account_url
|
||||
return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())
|
||||
51
account/sx/auth.sx
Normal file
51
account/sx/auth.sx
Normal file
@@ -0,0 +1,51 @@
|
||||
;; Auth page components (device auth — account-specific)
|
||||
;; Login and check-email components are shared: see shared/sx/templates/auth.sx
|
||||
|
||||
(defcomp ~account-device-error (&key error)
|
||||
(when error
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~account-device-form (&key error action csrf-token code)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
|
||||
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
|
||||
error
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(div
|
||||
(label :for "code" :class "block text-sm font-medium mb-1" "Device code")
|
||||
(input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"
|
||||
:required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"
|
||||
:class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
||||
(button :type "submit"
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Authorize"))))
|
||||
|
||||
(defcomp ~account-device-approved ()
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||
|
||||
;; Assembled auth page content — replaces Python _login_page_content etc.
|
||||
|
||||
(defcomp ~account-login-content (&key error email)
|
||||
(~auth-login-form
|
||||
:error (when error (~auth-error-banner :error error))
|
||||
:action (url-for "auth.start_login")
|
||||
:csrf-token (csrf-token)
|
||||
:email (or email "")))
|
||||
|
||||
(defcomp ~account-device-content (&key error code)
|
||||
(~account-device-form
|
||||
:error (when error (~account-device-error :error error))
|
||||
:action (url-for "auth.device_submit")
|
||||
:csrf-token (csrf-token)
|
||||
:code (or code "")))
|
||||
|
||||
(defcomp ~account-check-email-content (&key email email-error)
|
||||
(~auth-check-email
|
||||
:email (escape (or email ""))
|
||||
:error (when email-error
|
||||
(~auth-check-email-error :error (escape email-error)))))
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
(defcomp ~account-error-banner (&key error)
|
||||
(when error
|
||||
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
|
||||
(raw! error))))
|
||||
error)))
|
||||
|
||||
(defcomp ~account-user-email (&key email)
|
||||
(when email
|
||||
(p :class "text-sm text-stone-500 mt-1" (raw! email))))
|
||||
(p :class "text-sm text-stone-500 mt-1" email)))
|
||||
|
||||
(defcomp ~account-user-name (&key name)
|
||||
(when name
|
||||
(p :class "text-sm text-stone-600" (raw! name))))
|
||||
(p :class "text-sm text-stone-600" name)))
|
||||
|
||||
(defcomp ~account-logout-form (&key csrf-token)
|
||||
(form :action "/auth/logout/" :method "post"
|
||||
@@ -22,27 +22,39 @@
|
||||
|
||||
(defcomp ~account-label-item (&key name)
|
||||
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
|
||||
(raw! name)))
|
||||
name))
|
||||
|
||||
(defcomp ~account-labels-section (&key items-html)
|
||||
(when items-html
|
||||
(defcomp ~account-labels-section (&key items)
|
||||
(when items
|
||||
(div
|
||||
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
|
||||
(div :class "flex flex-wrap gap-2" (raw! items-html)))))
|
||||
(div :class "flex flex-wrap gap-2" items))))
|
||||
|
||||
(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html)
|
||||
(defcomp ~account-main-panel (&key error email name logout labels)
|
||||
(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"
|
||||
(raw! error-html)
|
||||
error
|
||||
(div :class "flex items-center justify-between"
|
||||
(div
|
||||
(h1 :class "text-xl font-semibold tracking-tight" "Account")
|
||||
(raw! email-html)
|
||||
(raw! name-html))
|
||||
(raw! logout-html))
|
||||
(raw! labels-html))))
|
||||
email
|
||||
name)
|
||||
logout)
|
||||
labels)))
|
||||
|
||||
;; Header child wrapper
|
||||
(defcomp ~account-header-child (&key inner-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
||||
(raw! inner-html)))
|
||||
;; Assembled dashboard content — replaces Python _account_main_panel_sx
|
||||
(defcomp ~account-dashboard-content (&key error)
|
||||
(let* ((user (current-user))
|
||||
(csrf (csrf-token)))
|
||||
(~account-main-panel
|
||||
:error (when error (~account-error-banner :error error))
|
||||
:email (when (get user "email")
|
||||
(~account-user-email :email (get user "email")))
|
||||
:name (when (get user "name")
|
||||
(~account-user-name :name (get user "name")))
|
||||
:logout (~account-logout-form :csrf-token csrf)
|
||||
:labels (when (not (empty? (or (get user "labels") (list))))
|
||||
(~account-labels-section
|
||||
:items (map (lambda (label)
|
||||
(~account-label-item :name (get label "name")))
|
||||
(get user "labels")))))))
|
||||
8
account/sx/handlers/auth-menu.sx
Normal file
8
account/sx/handlers/auth-menu.sx
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Account auth-menu fragment handler
|
||||
;;
|
||||
;; Renders the desktop + mobile auth menu (sign-in or user link).
|
||||
|
||||
(defhandler auth-menu (&key email)
|
||||
(~auth-menu
|
||||
:user-email (when email email)
|
||||
:account-url (app-url "account" "")))
|
||||
62
account/sx/newsletters.sx
Normal file
62
account/sx/newsletters.sx
Normal file
@@ -0,0 +1,62 @@
|
||||
;; Newsletter management components
|
||||
|
||||
(defcomp ~account-newsletter-desc (&key description)
|
||||
(when description
|
||||
(p :class "text-xs text-stone-500 mt-0.5 truncate" description)))
|
||||
|
||||
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
|
||||
(div :id id :class "flex items-center"
|
||||
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
|
||||
:class cls :role "switch" :aria-checked checked
|
||||
(span :class knob-cls))))
|
||||
|
||||
|
||||
(defcomp ~account-newsletter-item (&key name desc toggle)
|
||||
(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" name)
|
||||
desc)
|
||||
(div :class "ml-4 flex-shrink-0" toggle)))
|
||||
|
||||
(defcomp ~account-newsletter-list (&key items)
|
||||
(div :class "divide-y divide-stone-100" items))
|
||||
|
||||
(defcomp ~account-newsletter-empty ()
|
||||
(p :class "text-sm text-stone-500" "No newsletters available."))
|
||||
|
||||
(defcomp ~account-newsletters-panel (&key list)
|
||||
(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")
|
||||
list)))
|
||||
|
||||
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
|
||||
;; Takes pre-fetched newsletter-list from page helper
|
||||
(defcomp ~account-newsletters-content (&key newsletter-list account-url)
|
||||
(let* ((csrf (csrf-token)))
|
||||
(if (empty? newsletter-list)
|
||||
(~account-newsletter-empty)
|
||||
(~account-newsletters-panel
|
||||
:list (~account-newsletter-list
|
||||
:items (map (lambda (item)
|
||||
(let* ((nl (get item "newsletter"))
|
||||
(un (get item "un"))
|
||||
(nid (get nl "id"))
|
||||
(subscribed (get item "subscribed"))
|
||||
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
|
||||
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
|
||||
(translate (if subscribed "translate-x-6" "translate-x-1"))
|
||||
(checked (if subscribed "true" "false")))
|
||||
(~account-newsletter-item
|
||||
:name (get nl "name")
|
||||
:desc (when (get nl "description")
|
||||
(~account-newsletter-desc :description (get nl "description")))
|
||||
:toggle (~account-newsletter-toggle
|
||||
:id (str "nl-" nid)
|
||||
:url toggle-url
|
||||
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:target (str "#nl-" nid)
|
||||
:cls (str "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)
|
||||
:checked checked
|
||||
:knob-cls (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
|
||||
newsletter-list))))))
|
||||
91
account/sxc/pages/__init__.py
Normal file
91
account/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Account defpage setup — registers layouts and loads .sx pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def setup_account_pages() -> None:
|
||||
"""Register account-specific layouts and load page definitions."""
|
||||
_register_account_layouts()
|
||||
_load_account_page_files()
|
||||
|
||||
|
||||
def _load_account_page_files() -> None:
|
||||
import os
|
||||
from shared.sx.pages import load_page_dir
|
||||
load_page_dir(os.path.dirname(__file__), "account")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_account_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
|
||||
|
||||
|
||||
async def _account_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
|
||||
|
||||
root_hdr = await root_header_sx(ctx)
|
||||
auth_hdr = await render_to_sx("auth-header-row",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
account_nav=_as_sx_nav(ctx),
|
||||
)
|
||||
hdr_child = await header_child_sx(auth_hdr)
|
||||
return "(<> " + root_hdr + " " + hdr_child + ")"
|
||||
|
||||
|
||||
async def _account_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import root_header_sx, render_to_sx
|
||||
|
||||
auth_hdr = await render_to_sx("auth-header-row",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
account_nav=_as_sx_nav(ctx),
|
||||
oob=True,
|
||||
)
|
||||
return "(<> " + auth_hdr + " " + await root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
|
||||
async def _account_mobile(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
ctx = _inject_account_nav(ctx)
|
||||
nav_items = await render_to_sx("auth-nav-items",
|
||||
account_url=_call_url(ctx, "account_url", ""),
|
||||
select_colours=ctx.get("select_colours", ""),
|
||||
account_nav=_as_sx_nav(ctx),
|
||||
)
|
||||
auth_section = await render_to_sx("mobile-menu-section",
|
||||
label="account", href="/", level=1, colour="sky",
|
||||
items=SxExpr(nav_items))
|
||||
return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx))
|
||||
|
||||
|
||||
def _call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
fn = ctx.get(key)
|
||||
if callable(fn):
|
||||
return fn(path)
|
||||
return str(fn or "") + path
|
||||
|
||||
|
||||
def _inject_account_nav(ctx: dict) -> dict:
|
||||
"""Ensure account_nav is in ctx from g.account_nav."""
|
||||
if "account_nav" not in ctx:
|
||||
from quart import g
|
||||
ctx = dict(ctx)
|
||||
ctx["account_nav"] = getattr(g, "account_nav", "")
|
||||
return ctx
|
||||
|
||||
|
||||
def _as_sx_nav(ctx: dict) -> Any:
|
||||
"""Convert account_nav fragment to SxExpr for use in component calls."""
|
||||
from shared.sx.helpers import _as_sx
|
||||
ctx = _inject_account_nav(ctx)
|
||||
return _as_sx(ctx.get("account_nav"))
|
||||
|
||||
|
||||
40
account/sxc/pages/account.sx
Normal file
40
account/sxc/pages/account.sx
Normal file
@@ -0,0 +1,40 @@
|
||||
;; Account app — declarative page definitions
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Account dashboard
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage account-dashboard
|
||||
:path "/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (~account-dashboard-content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Newsletters
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage newsletters
|
||||
:path "/newsletters/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:data (service "account-page" "newsletters-data")
|
||||
:content (~account-newsletters-content
|
||||
:newsletter-list newsletter-list
|
||||
:account-url account-url))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Fragment pages (tickets, bookings, etc. from events service)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage fragment-page
|
||||
:path "/<slug>/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (let* ((user (current-user))
|
||||
(result (frag "events" "account-page"
|
||||
:slug slug
|
||||
:user-id (str (get user "id")))))
|
||||
(if (or (nil? result) (empty? result))
|
||||
(abort 404)
|
||||
result)))
|
||||
@@ -1,44 +0,0 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
|
||||
|
||||
{% if bookings %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for booking in bookings %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if booking.end_at %}
|
||||
<span>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
||||
{% endif %}
|
||||
{% if booking.calendar_name %}
|
||||
<span>· {{ booking.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if booking.cost %}
|
||||
<span>· £{{ booking.cost }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if booking.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% elif booking.state == 'provisional' %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No bookings yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
{{ page_fragment_html | safe }}
|
||||
@@ -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="/auth/logout/" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
|
||||
>
|
||||
<i class="fa-solid fa-right-from-bracket text-xs"></i>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Labels #}
|
||||
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
|
||||
{% if labels %}
|
||||
<div>
|
||||
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for label in labels %}
|
||||
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
newsletters
|
||||
{% endcall %}
|
||||
{% if account_nav_html %}
|
||||
{{ account_nav_html | safe }}
|
||||
{% endif %}
|
||||
@@ -1,17 +0,0 @@
|
||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
||||
<button
|
||||
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-target="#nl-{{ un.newsletter_id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
|
||||
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
|
||||
role="switch"
|
||||
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
|
||||
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-target="#nl-{{ item.newsletter.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No newsletters available.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/auth/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include oob.main %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
|
||||
|
||||
{% if tickets %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for ticket in tickets %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
|
||||
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
|
||||
{{ ticket.entry_name }}
|
||||
</a>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if ticket.calendar_name %}
|
||||
<span>· {{ ticket.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if ticket.ticket_type_name %}
|
||||
<span>· {{ ticket.ticket_type_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if ticket.state == 'checked_in' %}
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
|
||||
{% elif ticket.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No tickets yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>,
|
||||
you’ll 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="{{ blog_url('/auth/login/') }}"
|
||||
class="text-stone-600 dark:text-stone-300 hover:underline"
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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(account_url('/'), 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 we’ll 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="{{ blog_url('/auth/start/') }}"
|
||||
class="mt-6 space-y-5"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ email or '' }}"
|
||||
required
|
||||
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
|
||||
>
|
||||
Send link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Check your email — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
|
||||
<p class="text-stone-600 mb-2">
|
||||
We sent a sign-in link to <strong>{{ email }}</strong>.
|
||||
</p>
|
||||
<p class="text-stone-500 text-sm">
|
||||
Click the link in the email to sign in. The link expires in 15 minutes.
|
||||
</p>
|
||||
{% if email_error %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
|
||||
{{ email_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Authorize Device — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Authorize device</h1>
|
||||
<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.device_submit') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium mb-1">Device code</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
id="code"
|
||||
value="{{ code | default('') }}"
|
||||
placeholder="XXXX-XXXX"
|
||||
required
|
||||
autofocus
|
||||
maxlength="9"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
>
|
||||
Authorize
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,9 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Device Authorized — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Device authorized</h1>
|
||||
<p class="text-stone-600">You can close this window and return to your terminal.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,36 +0,0 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Login — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ email | default('') }}"
|
||||
required
|
||||
autofocus
|
||||
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
>
|
||||
Send magic link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
blog/actions.sx
Normal file
12
blog/actions.sx
Normal file
@@ -0,0 +1,12 @@
|
||||
;; Blog service — inter-service action endpoints
|
||||
|
||||
(defaction update-page-config (&key container-type container-id
|
||||
features sumup-merchant-code
|
||||
sumup-checkout-prefix sumup-api-key)
|
||||
"Create or update a PageConfig with features and SumUp settings."
|
||||
(service "page-config" "update"
|
||||
:container-type container-type :container-id container-id
|
||||
:features features
|
||||
:sumup-merchant-code sumup-merchant-code
|
||||
:sumup-checkout-prefix sumup-checkout-prefix
|
||||
:sumup-api-key sumup-api-key))
|
||||
@@ -2,7 +2,7 @@ from alembic import context
|
||||
from shared.db.alembic_env import run_alembic
|
||||
|
||||
MODELS = [
|
||||
"shared.models.ghost_content",
|
||||
"blog.models.content",
|
||||
"shared.models.kv",
|
||||
"shared.models.menu_item",
|
||||
"shared.models.menu_node",
|
||||
@@ -13,6 +13,7 @@ MODELS = [
|
||||
|
||||
TABLES = frozenset({
|
||||
"posts", "authors", "post_authors", "tags", "post_tags",
|
||||
"post_users",
|
||||
"snippets", "tag_groups", "tag_group_tags",
|
||||
"menu_items", "menu_nodes", "kv",
|
||||
"page_configs",
|
||||
|
||||
67
blog/alembic/versions/0004_ghost_id_nullable_and_defaults.py
Normal file
67
blog/alembic/versions/0004_ghost_id_nullable_and_defaults.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Make ghost_id nullable, add defaults, create post_users M2M table.
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003_add_page_configs
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "blog_0004"
|
||||
down_revision = "blog_0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Make ghost_id nullable
|
||||
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# Add server defaults for Post
|
||||
op.alter_column(
|
||||
"posts", "uuid",
|
||||
existing_type=sa.String(64),
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
)
|
||||
op.alter_column(
|
||||
"posts", "updated_at",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
op.alter_column(
|
||||
"posts", "created_at",
|
||||
existing_type=sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
)
|
||||
|
||||
# Create post_users M2M table (replaces post_authors for new posts)
|
||||
op.create_table(
|
||||
"post_users",
|
||||
sa.Column("post_id", sa.Integer, sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, primary_key=True),
|
||||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index("ix_post_users_user_id", "post_users", ["user_id"])
|
||||
|
||||
# Backfill post_users from post_authors for posts that already have user_id.
|
||||
# This maps each post's authors to the post's user_id (primary author).
|
||||
# Multi-author mapping requires the full sync script.
|
||||
op.execute("""
|
||||
INSERT INTO post_users (post_id, user_id, sort_order)
|
||||
SELECT p.id, p.user_id, 0
|
||||
FROM posts p
|
||||
WHERE p.user_id IS NOT NULL
|
||||
AND p.deleted_at IS NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("post_users")
|
||||
op.alter_column("posts", "created_at", existing_type=sa.DateTime(timezone=True), server_default=None)
|
||||
op.alter_column("posts", "updated_at", existing_type=sa.DateTime(timezone=True), server_default=None)
|
||||
op.alter_column("posts", "uuid", existing_type=sa.String(64), server_default=None)
|
||||
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Add sx_content column to posts table.
|
||||
|
||||
Revision ID: blog_0005
|
||||
Revises: blog_0004
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "blog_0005"
|
||||
down_revision = "blog_0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("posts", sa.Column("sx_content", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("posts", "sx_content")
|
||||
40
blog/app.py
40
blog/app.py
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, request
|
||||
@@ -16,10 +15,10 @@ from bp import (
|
||||
register_admin,
|
||||
register_menu_items,
|
||||
register_snippets,
|
||||
register_fragments,
|
||||
register_data,
|
||||
register_actions,
|
||||
)
|
||||
from sxc.pages import setup_blog_pages
|
||||
|
||||
|
||||
async def blog_context() -> dict:
|
||||
@@ -27,7 +26,7 @@ async def blog_context() -> dict:
|
||||
Blog app context processor.
|
||||
|
||||
- cart_count/cart_total: via cart service (shared DB)
|
||||
- cart_mini_html / auth_menu_html / nav_tree_html: pre-fetched fragments
|
||||
- cart_mini / auth_menu / nav_tree: pre-fetched fragments
|
||||
"""
|
||||
from shared.infrastructure.context import base_context
|
||||
from shared.services.navigation import get_navigation_tree
|
||||
@@ -65,14 +64,14 @@ async def blog_context() -> dict:
|
||||
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_mini, auth_menu, nav_tree = 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
|
||||
ctx["cart_mini"] = cart_mini
|
||||
ctx["auth_menu"] = auth_menu
|
||||
ctx["nav_tree"] = nav_tree
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -80,6 +79,8 @@ async def blog_context() -> dict:
|
||||
def create_app() -> "Quart":
|
||||
from services import register_domain_services
|
||||
|
||||
setup_blog_pages()
|
||||
|
||||
app = create_base_app(
|
||||
"blog",
|
||||
context_fn=blog_context,
|
||||
@@ -105,7 +106,9 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_admin("/settings"))
|
||||
app.register_blueprint(register_menu_items())
|
||||
app.register_blueprint(register_snippets())
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "blog")
|
||||
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -134,7 +137,7 @@ def create_app() -> "Quart":
|
||||
async def oembed():
|
||||
from urllib.parse import urlparse
|
||||
from quart import jsonify
|
||||
from shared.services.registry import services
|
||||
from services import blog_service
|
||||
from shared.infrastructure.urls import blog_url
|
||||
from shared.infrastructure.oembed import build_oembed_response
|
||||
|
||||
@@ -147,7 +150,7 @@ def create_app() -> "Quart":
|
||||
if not slug:
|
||||
return jsonify({"error": "could not extract slug"}), 404
|
||||
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
post = await blog_service.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
@@ -159,6 +162,23 @@ def create_app() -> "Quart":
|
||||
)
|
||||
return jsonify(resp)
|
||||
|
||||
# Auto-mount all defpages with absolute paths
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "blog")
|
||||
|
||||
# --- Pass defpage helper data to template context for layouts ---
|
||||
@app.context_processor
|
||||
async def inject_blog_data():
|
||||
import os
|
||||
from shared.config import config as get_config
|
||||
ctx = {
|
||||
"blog_title": get_config()["blog_title"],
|
||||
"base_title": get_config()["title"],
|
||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
}
|
||||
ctx.update(getattr(g, '_defpage_ctx', {}))
|
||||
return ctx
|
||||
|
||||
# --- debug: url rules ---
|
||||
@app.get("/__rules")
|
||||
async def dump_rules():
|
||||
|
||||
@@ -2,6 +2,5 @@ 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 .fragments import register_fragments
|
||||
from .data import register_data
|
||||
from .actions.routes import register as register_actions
|
||||
|
||||
@@ -1,96 +1,14 @@
|
||||
"""Blog app action endpoints.
|
||||
|
||||
Exposes write operations at ``/internal/actions/<action_name>`` for
|
||||
cross-app callers via the internal action client.
|
||||
All actions are defined in ``blog/actions.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.actions import ACTION_HEADER
|
||||
from shared.infrastructure.query_blueprint import create_action_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.post("/<action_name>")
|
||||
async def handle_action(action_name: str):
|
||||
handler = _handlers.get(action_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown action"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result or {"ok": True})
|
||||
|
||||
# --- update-page-config ---
|
||||
async def _update_page_config():
|
||||
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
|
||||
from shared.models.page_config import PageConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
container_type = data.get("container_type", "page")
|
||||
container_id = data.get("container_id")
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=data.get("features", {}),
|
||||
)
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
|
||||
if "features" in data:
|
||||
features = dict(pc.features or {})
|
||||
for key, val in data["features"].items():
|
||||
if isinstance(val, bool):
|
||||
features[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
features[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
features[key] = False
|
||||
pc.features = features
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if "sumup_merchant_code" in data:
|
||||
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
|
||||
if "sumup_checkout_prefix" in data:
|
||||
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
|
||||
if "sumup_api_key" in data:
|
||||
pc.sumup_api_key = data["sumup_api_key"] or None
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
"sumup_configured": bool(pc.sumup_api_key),
|
||||
}
|
||||
|
||||
_handlers["update-page-config"] = _update_page_config
|
||||
|
||||
bp, _handlers = create_action_blueprint("blog")
|
||||
return bp
|
||||
|
||||
@@ -3,17 +3,14 @@ from __future__ import annotations
|
||||
#from quart import Blueprint, g
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
redirect,
|
||||
url_for,
|
||||
request,
|
||||
jsonify
|
||||
)
|
||||
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.sx.helpers import sx_response
|
||||
from shared.config import config
|
||||
from datetime import datetime
|
||||
|
||||
@@ -26,44 +23,17 @@ def register(url_prefix):
|
||||
"base_title": f"{config()['title']} settings",
|
||||
}
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def home():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_settings_page, render_settings_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_settings_page(tctx)
|
||||
else:
|
||||
html = await render_settings_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/cache/")
|
||||
@require_admin
|
||||
async def cache():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_cache_page, render_cache_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_cache_page(tctx)
|
||||
else:
|
||||
html = await render_cache_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.post("/cache_clear/")
|
||||
@require_admin
|
||||
async def cache_clear():
|
||||
await clear_all_cache()
|
||||
if is_htmx_request():
|
||||
now = datetime.now()
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
from shared.sx.jinja_bridge import render as render_comp
|
||||
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
||||
return html
|
||||
return sx_response(html)
|
||||
|
||||
return redirect(url_for("settings.cache"))
|
||||
return redirect(url_for("defpage_cache_page"))
|
||||
return bp
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
redirect,
|
||||
url_for,
|
||||
@@ -13,7 +11,6 @@ from quart import (
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
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
|
||||
@@ -45,35 +42,13 @@ async def _unassigned_tags(session):
|
||||
def register():
|
||||
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def index():
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
|
||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_tag_groups_page, render_tag_groups_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await make_response(await render_tag_groups_page(tctx))
|
||||
else:
|
||||
return await make_response(await render_tag_groups_oob(tctx))
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
async def create():
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
if not name:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
slug = _slugify(name)
|
||||
feature_image = (form.get("feature_image") or "").strip() or None
|
||||
@@ -89,55 +64,14 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
@bp.get("/<int:id>/")
|
||||
@require_admin
|
||||
async def edit(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
# Assigned tag IDs for this group
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
assigned_tag_ids = set(assigned_rows)
|
||||
|
||||
# All public, non-deleted tags
|
||||
all_tags = list(
|
||||
(await g.s.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
|
||||
ctx = {
|
||||
"group": tg,
|
||||
"all_tags": all_tags,
|
||||
"assigned_tag_ids": assigned_tag_ids,
|
||||
}
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await make_response(await render_tag_group_edit_page(tctx))
|
||||
else:
|
||||
return await make_response(await render_tag_group_edit_oob(tctx))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
@bp.post("/<int:id>/")
|
||||
@require_admin
|
||||
async def save(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
@@ -168,7 +102,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
|
||||
return redirect(url_for("defpage_tag_group_edit", id=id))
|
||||
|
||||
@bp.post("/<int:id>/delete/")
|
||||
@require_admin
|
||||
@@ -178,6 +112,6 @@ def register():
|
||||
await g.s.delete(tg)
|
||||
await g.s.flush()
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
return bp
|
||||
|
||||
445
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
445
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Lexical JSON → s-expression converter.
|
||||
|
||||
Mirrors lexical_renderer.py's registry/dispatch pattern but produces sx source
|
||||
instead of HTML. Used for backfilling existing posts and on-the-fly conversion
|
||||
when editing pre-migration posts in the SX editor.
|
||||
|
||||
Public API
|
||||
----------
|
||||
lexical_to_sx(doc) – Lexical JSON (dict or string) → sx source string
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
import mistune
|
||||
|
||||
from shared.sx.html_to_sx import html_to_sx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONVERTERS: dict[str, Callable[[dict], str]] = {}
|
||||
|
||||
|
||||
def _converter(node_type: str):
|
||||
"""Decorator — register a function as the converter for *node_type*."""
|
||||
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
|
||||
_CONVERTERS[node_type] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lexical_to_sx(doc: dict | str) -> str:
|
||||
"""Convert a Lexical JSON document to an sx source string."""
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
root = doc.get("root", doc)
|
||||
children = root.get("children", [])
|
||||
parts = [_convert_node(c) for c in children]
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
return '(<> (p ""))'
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return "(<>\n " + "\n ".join(parts) + ")"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _convert_node(node: dict) -> str:
|
||||
node_type = node.get("type", "")
|
||||
converter = _CONVERTERS.get(node_type)
|
||||
if converter:
|
||||
return converter(node)
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_children(children: list[dict]) -> str:
|
||||
"""Convert children to inline sx content (for text nodes)."""
|
||||
parts = [_convert_node(c) for c in children]
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
"""Escape a string for sx double-quoted literals."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text format bitmask
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FORMAT_BOLD = 1
|
||||
_FORMAT_ITALIC = 2
|
||||
_FORMAT_STRIKETHROUGH = 4
|
||||
_FORMAT_UNDERLINE = 8
|
||||
_FORMAT_CODE = 16
|
||||
_FORMAT_SUBSCRIPT = 32
|
||||
_FORMAT_SUPERSCRIPT = 64
|
||||
|
||||
_FORMAT_WRAPPERS: list[tuple[int, str]] = [
|
||||
(_FORMAT_BOLD, "strong"),
|
||||
(_FORMAT_ITALIC, "em"),
|
||||
(_FORMAT_STRIKETHROUGH, "s"),
|
||||
(_FORMAT_UNDERLINE, "u"),
|
||||
(_FORMAT_CODE, "code"),
|
||||
(_FORMAT_SUBSCRIPT, "sub"),
|
||||
(_FORMAT_SUPERSCRIPT, "sup"),
|
||||
]
|
||||
|
||||
|
||||
def _wrap_format(text_sx: str, fmt: int) -> str:
|
||||
for mask, tag in _FORMAT_WRAPPERS:
|
||||
if fmt & mask:
|
||||
text_sx = f"({tag} {text_sx})"
|
||||
return text_sx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 — text nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_converter("text")
|
||||
def _text(node: dict) -> str:
|
||||
text = node.get("text", "")
|
||||
if not text:
|
||||
return ""
|
||||
sx = f'"{_esc(text)}"'
|
||||
fmt = node.get("format", 0)
|
||||
if isinstance(fmt, int) and fmt:
|
||||
sx = _wrap_format(sx, fmt)
|
||||
return sx
|
||||
|
||||
|
||||
@_converter("linebreak")
|
||||
def _linebreak(_node: dict) -> str:
|
||||
return '"\\n"'
|
||||
|
||||
|
||||
@_converter("tab")
|
||||
def _tab(_node: dict) -> str:
|
||||
return '"\\t"'
|
||||
|
||||
|
||||
@_converter("paragraph")
|
||||
def _paragraph(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
if not inner:
|
||||
inner = '""'
|
||||
return f"(p {inner})"
|
||||
|
||||
|
||||
@_converter("extended-text")
|
||||
def _extended_text(node: dict) -> str:
|
||||
# extended-text can be block-level (with children) or inline (with text).
|
||||
# When it has a "text" field, treat it as a plain text node.
|
||||
if "text" in node:
|
||||
return _text(node)
|
||||
return _paragraph(node)
|
||||
|
||||
|
||||
@_converter("heading")
|
||||
def _heading(node: dict) -> str:
|
||||
tag = node.get("tag", "h2")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
if not inner:
|
||||
inner = '""'
|
||||
return f"({tag} {inner})"
|
||||
|
||||
|
||||
@_converter("extended-heading")
|
||||
def _extended_heading(node: dict) -> str:
|
||||
if "text" in node:
|
||||
return _text(node)
|
||||
return _heading(node)
|
||||
|
||||
|
||||
@_converter("quote")
|
||||
def _quote(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"(blockquote {inner})" if inner else '(blockquote "")'
|
||||
|
||||
|
||||
@_converter("extended-quote")
|
||||
def _extended_quote(node: dict) -> str:
|
||||
if "text" in node:
|
||||
return _text(node)
|
||||
return _quote(node)
|
||||
|
||||
|
||||
@_converter("link")
|
||||
def _link(node: dict) -> str:
|
||||
href = node.get("url", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
if not inner:
|
||||
inner = f'"{_esc(href)}"'
|
||||
return f'(a :href "{_esc(href)}" {inner})'
|
||||
|
||||
|
||||
@_converter("autolink")
|
||||
def _autolink(node: dict) -> str:
|
||||
return _link(node)
|
||||
|
||||
|
||||
@_converter("at-link")
|
||||
def _at_link(node: dict) -> str:
|
||||
return _link(node)
|
||||
|
||||
|
||||
@_converter("list")
|
||||
def _list(node: dict) -> str:
|
||||
tag = "ol" if node.get("listType") == "number" else "ul"
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"({tag} {inner})" if inner else f"({tag})"
|
||||
|
||||
|
||||
@_converter("listitem")
|
||||
def _listitem(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"(li {inner})" if inner else '(li "")'
|
||||
|
||||
|
||||
@_converter("horizontalrule")
|
||||
def _horizontalrule(_node: dict) -> str:
|
||||
return "(hr)"
|
||||
|
||||
|
||||
@_converter("code")
|
||||
def _code(node: dict) -> str:
|
||||
inner = _convert_children(node.get("children", []))
|
||||
return f"(code {inner})" if inner else ""
|
||||
|
||||
|
||||
@_converter("codeblock")
|
||||
def _codeblock(node: dict) -> str:
|
||||
lang = node.get("language", "")
|
||||
code = node.get("code", "")
|
||||
lang_attr = f' :class "language-{_esc(lang)}"' if lang else ""
|
||||
return f'(pre (code{lang_attr} "{_esc(code)}"))'
|
||||
|
||||
|
||||
@_converter("code-highlight")
|
||||
def _code_highlight(node: dict) -> str:
|
||||
text = node.get("text", "")
|
||||
return f'"{_esc(text)}"' if text else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2 — common cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@_converter("image")
|
||||
def _image(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
alt = node.get("alt", "")
|
||||
caption = node.get("caption", "")
|
||||
width = node.get("cardWidth", "") or node.get("width", "")
|
||||
href = node.get("href", "")
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if alt:
|
||||
parts.append(f':alt "{_esc(alt)}"')
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
if width:
|
||||
parts.append(f':width "{_esc(width)}"')
|
||||
if href:
|
||||
parts.append(f':href "{_esc(href)}"')
|
||||
return "(~kg-image " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("gallery")
|
||||
def _gallery(node: dict) -> str:
|
||||
images = node.get("images", [])
|
||||
if not images:
|
||||
return ""
|
||||
|
||||
# Group images into rows of 3 (matching lexical_renderer.py)
|
||||
rows = []
|
||||
for i in range(0, len(images), 3):
|
||||
row_imgs = images[i:i + 3]
|
||||
row_items = []
|
||||
for img in row_imgs:
|
||||
item_parts = [f'"src" "{_esc(img.get("src", ""))}"']
|
||||
if img.get("alt"):
|
||||
item_parts.append(f'"alt" "{_esc(img["alt"])}"')
|
||||
if img.get("caption"):
|
||||
item_parts.append(f'"caption" {html_to_sx(img["caption"])}')
|
||||
row_items.append("(dict " + " ".join(item_parts) + ")")
|
||||
rows.append("(list " + " ".join(row_items) + ")")
|
||||
|
||||
images_sx = "(list " + " ".join(rows) + ")"
|
||||
caption = node.get("caption", "")
|
||||
caption_attr = f" :caption {html_to_sx(caption)}" if caption else ""
|
||||
return f"(~kg-gallery :images {images_sx}{caption_attr})"
|
||||
|
||||
|
||||
@_converter("html")
|
||||
def _html_card(node: dict) -> str:
|
||||
raw = node.get("html", "")
|
||||
inner = html_to_sx(raw)
|
||||
return f"(~kg-html {inner})"
|
||||
|
||||
|
||||
@_converter("embed")
|
||||
def _embed(node: dict) -> str:
|
||||
embed_html = node.get("html", "")
|
||||
caption = node.get("caption", "")
|
||||
parts = [f':html "{_esc(embed_html)}"']
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg-embed " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("bookmark")
|
||||
def _bookmark(node: dict) -> str:
|
||||
url = node.get("url", "")
|
||||
meta = node.get("metadata", {})
|
||||
parts = [f':url "{_esc(url)}"']
|
||||
|
||||
title = meta.get("title", "") or node.get("title", "")
|
||||
if title:
|
||||
parts.append(f':title "{_esc(title)}"')
|
||||
desc = meta.get("description", "") or node.get("description", "")
|
||||
if desc:
|
||||
parts.append(f':description "{_esc(desc)}"')
|
||||
icon = meta.get("icon", "") or node.get("icon", "")
|
||||
if icon:
|
||||
parts.append(f':icon "{_esc(icon)}"')
|
||||
author = meta.get("author", "") or node.get("author", "")
|
||||
if author:
|
||||
parts.append(f':author "{_esc(author)}"')
|
||||
publisher = meta.get("publisher", "") or node.get("publisher", "")
|
||||
if publisher:
|
||||
parts.append(f':publisher "{_esc(publisher)}"')
|
||||
thumbnail = meta.get("thumbnail", "") or node.get("thumbnail", "")
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
caption = node.get("caption", "")
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
|
||||
return "(~kg-bookmark " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("callout")
|
||||
def _callout(node: dict) -> str:
|
||||
color = node.get("backgroundColor", "grey")
|
||||
emoji = node.get("calloutEmoji", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
|
||||
parts = [f':color "{_esc(color)}"']
|
||||
if emoji:
|
||||
parts.append(f':emoji "{_esc(emoji)}"')
|
||||
if inner:
|
||||
parts.append(f':content {inner}')
|
||||
return "(~kg-callout " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("button")
|
||||
def _button(node: dict) -> str:
|
||||
text = node.get("buttonText", "")
|
||||
url = node.get("buttonUrl", "")
|
||||
alignment = node.get("alignment", "center")
|
||||
return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
|
||||
|
||||
|
||||
@_converter("toggle")
|
||||
def _toggle(node: dict) -> str:
|
||||
heading = node.get("heading", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
content_attr = f" :content {inner}" if inner else ""
|
||||
return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})'
|
||||
|
||||
|
||||
@_converter("audio")
|
||||
def _audio(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
title = node.get("title", "")
|
||||
duration = node.get("duration", 0)
|
||||
thumbnail = node.get("thumbnailSrc", "")
|
||||
|
||||
duration_min = int(duration) // 60
|
||||
duration_sec = int(duration) % 60
|
||||
duration_str = f"{duration_min}:{duration_sec:02d}"
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if title:
|
||||
parts.append(f':title "{_esc(title)}"')
|
||||
parts.append(f':duration "{duration_str}"')
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
return "(~kg-audio " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("video")
|
||||
def _video(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
caption = node.get("caption", "")
|
||||
width = node.get("cardWidth", "")
|
||||
thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
|
||||
loop = node.get("loop", False)
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
if width:
|
||||
parts.append(f':width "{_esc(width)}"')
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
if loop:
|
||||
parts.append(":loop true")
|
||||
return "(~kg-video " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("file")
|
||||
def _file(node: dict) -> str:
|
||||
src = node.get("src", "")
|
||||
filename = node.get("fileName", "")
|
||||
title = node.get("title", "") or filename
|
||||
file_size = node.get("fileSize", 0)
|
||||
caption = node.get("caption", "")
|
||||
|
||||
# Format size
|
||||
size_str = ""
|
||||
if file_size:
|
||||
kb = file_size / 1024
|
||||
if kb < 1024:
|
||||
size_str = f"{kb:.0f} KB"
|
||||
else:
|
||||
size_str = f"{kb / 1024:.1f} MB"
|
||||
|
||||
parts = [f':src "{_esc(src)}"']
|
||||
if filename:
|
||||
parts.append(f':filename "{_esc(filename)}"')
|
||||
if title:
|
||||
parts.append(f':title "{_esc(title)}"')
|
||||
if size_str:
|
||||
parts.append(f':filesize "{size_str}"')
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg-file " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("paywall")
|
||||
def _paywall(_node: dict) -> str:
|
||||
return "(~kg-paywall)"
|
||||
|
||||
|
||||
@_converter("markdown")
|
||||
def _markdown(node: dict) -> str:
|
||||
md_text = node.get("markdown", "")
|
||||
rendered = mistune.html(md_text)
|
||||
inner = html_to_sx(rendered)
|
||||
return f"(~kg-md {inner})"
|
||||
@@ -63,6 +63,7 @@ def _post_to_public(p: Post) -> Dict[str, Any]:
|
||||
"slug": p.slug,
|
||||
"title": p.title,
|
||||
"html": p.html,
|
||||
"sx_content": p.sx_content,
|
||||
"is_page": p.is_page,
|
||||
"excerpt": p.custom_excerpt or p.excerpt,
|
||||
"custom_excerpt": p.custom_excerpt,
|
||||
|
||||
@@ -14,7 +14,6 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
from .ghost_db import DBClient # adjust import path
|
||||
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
|
||||
@@ -22,6 +21,7 @@ from .services.pages_data import pages_data
|
||||
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.sx.helpers import sx_response, render_to_sx
|
||||
from shared.utils import host_url
|
||||
|
||||
def register(url_prefix, title):
|
||||
@@ -46,39 +46,14 @@ def register(url_prefix, title):
|
||||
|
||||
@blogs_bp.before_app_serving
|
||||
async def init():
|
||||
from .ghost.ghost_sync import sync_all_content_from_ghost
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Advisory lock prevents multiple Hypercorn workers from
|
||||
# running the sync concurrently (which causes PK conflicts).
|
||||
async with get_session() as s:
|
||||
got_lock = await s.scalar(text("SELECT pg_try_advisory_lock(900001)"))
|
||||
if not got_lock:
|
||||
await s.rollback() # clean up before returning connection to pool
|
||||
return
|
||||
try:
|
||||
await sync_all_content_from_ghost(s)
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("Ghost sync failed — will retry on next deploy")
|
||||
try:
|
||||
await s.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await s.execute(text("SELECT pg_advisory_unlock(900001)"))
|
||||
await s.commit()
|
||||
except Exception:
|
||||
pass # lock auto-releases when session closes
|
||||
# Ghost startup sync disabled (Phase 1) — blog service owns content
|
||||
# directly. The final_ghost_sync.py script was run before cutover.
|
||||
pass
|
||||
|
||||
@blogs_bp.before_request
|
||||
def route():
|
||||
async def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
|
||||
|
||||
@blogs_bp.context_processor
|
||||
async def inject_root():
|
||||
return {
|
||||
@@ -87,6 +62,19 @@ def register(url_prefix, title):
|
||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
}
|
||||
|
||||
async def _render_new_post_page(tctx):
|
||||
"""Compose a full page with blog header for new post/page creation."""
|
||||
from shared.sx.helpers import root_header_sx, full_page_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
blog_hdr = await render_to_sx("menu-row-sx",
|
||||
id="blog-row", level=1,
|
||||
link_label_content=SxExpr("(div)"),
|
||||
child_id="blog-header-child")
|
||||
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
content = tctx.get("editor_html", "")
|
||||
return await full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||
|
||||
SORT_MAP = {
|
||||
"newest": "published_at DESC",
|
||||
"oldest": "published_at ASC",
|
||||
@@ -117,7 +105,7 @@ def register(url_prefix, title):
|
||||
post_slug = p_data["post"]["slug"]
|
||||
|
||||
# Fetch container nav from relations service
|
||||
container_nav_html = await fetch_fragment("relations", "container-nav", params={
|
||||
container_nav = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(db_post_id),
|
||||
"post_slug": post_slug,
|
||||
@@ -126,7 +114,7 @@ def register(url_prefix, title):
|
||||
ctx = {
|
||||
**p_data,
|
||||
"base_title": get_config()["title"],
|
||||
"container_nav_html": container_nav_html,
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
|
||||
# Page cart badge via HTTP
|
||||
@@ -142,118 +130,91 @@ def register(url_prefix, title):
|
||||
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)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_home_page, render_home_oob
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import (
|
||||
render_to_sx, root_header_sx, full_page_sx, oob_page_sx,
|
||||
post_header_sx, oob_header_sx, mobile_menu_sx,
|
||||
post_mobile_nav_sx, mobile_root_nav_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.services.registry import services
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
|
||||
post = ctx.get("post", {})
|
||||
content = await render_to_sx("blog-home-main",
|
||||
html_content=post.get("html", ""),
|
||||
sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None)
|
||||
meta_data = services.get("blog_page").post_meta_data(post, ctx.get("base_title", ""))
|
||||
meta = await render_to_sx("blog-meta", **meta_data)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_home_page(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
|
||||
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
return await make_response(html)
|
||||
else:
|
||||
html = await render_home_oob(tctx)
|
||||
return await make_response(html)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
|
||||
sx_src = await oob_page_sx(oobs=header_oob, content=content)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@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")
|
||||
|
||||
if content_type == "pages":
|
||||
data = await pages_data(g.s, q.page, q.search)
|
||||
context = {
|
||||
**data,
|
||||
"content_type": "pages",
|
||||
"search": q.search,
|
||||
"selected_tags": (),
|
||||
"selected_authors": (),
|
||||
"selected_groups": (),
|
||||
"sort": None,
|
||||
"view": None,
|
||||
"drafts": None,
|
||||
"draft_count": 0,
|
||||
"tags": [],
|
||||
"authors": [],
|
||||
"tag_groups": [],
|
||||
"posts": data.get("pages", []),
|
||||
}
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(context)
|
||||
if not is_htmx_request():
|
||||
html = await render_blog_page(tctx)
|
||||
elif q.page > 1:
|
||||
html = await render_blog_page_cards(tctx)
|
||||
else:
|
||||
html = await render_blog_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
# Default: posts listing
|
||||
# Drafts filter requires login; ignore if not logged in
|
||||
show_drafts = bool(q.drafts and g.user)
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
|
||||
|
||||
# For the draft count badge: admin sees all drafts, non-admin sees own
|
||||
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
|
||||
|
||||
data = await posts_data(
|
||||
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
|
||||
drafts=show_drafts, drafts_user_id=drafts_user_id,
|
||||
count_drafts_for_user_id=count_drafts_uid,
|
||||
selected_groups=q.selected_groups,
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import (
|
||||
render_to_sx, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
context = {
|
||||
**data,
|
||||
"content_type": "posts",
|
||||
"selected_tags": q.selected_tags,
|
||||
"selected_authors": q.selected_authors,
|
||||
"selected_groups": q.selected_groups,
|
||||
"sort": q.sort,
|
||||
"search": q.search,
|
||||
"view": q.view,
|
||||
"drafts": q.drafts if show_drafts else None,
|
||||
}
|
||||
async def _blog_hdr(ctx, oob=False):
|
||||
return await render_to_sx("menu-row-sx",
|
||||
id="blog-row", level=1,
|
||||
link_label_content=SxExpr("(div)"),
|
||||
child_id="blog-header-child", oob=oob)
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_cards
|
||||
data = await services.get("blog_page").index_data(g.s)
|
||||
|
||||
# Render content, aside, and filter via .sx defcomps
|
||||
content = await render_to_sx("blog-index-main-content", **data)
|
||||
aside = await render_to_sx("blog-index-aside-content", **data)
|
||||
filter_sx = await render_to_sx("blog-index-filter-content", **data)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
tctx = await get_template_context()
|
||||
tctx.update(context)
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_blog_page(tctx)
|
||||
elif q.page > 1:
|
||||
html = await render_blog_cards(tctx)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
blog_hdr = await _blog_hdr(tctx)
|
||||
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
html = await full_page_sx(tctx, header_rows=header_rows,
|
||||
content=content, aside=aside, filter=filter_sx)
|
||||
return await make_response(html)
|
||||
elif data.get("page", 1) > 1:
|
||||
# Pagination — return just the cards
|
||||
return sx_response(content)
|
||||
else:
|
||||
html = await render_blog_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@blogs_bp.get("/new/")
|
||||
@require_admin
|
||||
async def new_post():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel()
|
||||
if not is_htmx_request():
|
||||
html = await render_new_post_page(tctx)
|
||||
else:
|
||||
html = await render_new_post_oob(tctx)
|
||||
return await make_response(html)
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
blog_hdr = await _blog_hdr(tctx)
|
||||
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
header_oob = await oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
sx_src = await oob_page_sx(oobs=header_oob, content=content,
|
||||
aside=aside, filter=filter_sx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.post("/new/")
|
||||
@require_admin
|
||||
async def new_post_save():
|
||||
from .ghost.ghost_posts import create_post
|
||||
from .ghost.lexical_validator import validate_lexical
|
||||
from .ghost.ghost_sync import sync_single_post
|
||||
from services.post_writer import create_post as writer_create
|
||||
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip() or "Untitled"
|
||||
@@ -267,74 +228,49 @@ def register(url_prefix, title):
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||
html = await render_new_post_page(tctx)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason)
|
||||
html = await render_new_post_page(tctx)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error=reason)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
# Create in Ghost
|
||||
ghost_post = await create_post(
|
||||
# Create directly in db_blog
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
post = await writer_create(
|
||||
g.s,
|
||||
title=title,
|
||||
lexical_json=lexical_raw,
|
||||
status=status,
|
||||
user_id=g.user.id,
|
||||
feature_image=feature_image or None,
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
sx_content=sx_content_raw,
|
||||
)
|
||||
|
||||
# Sync to local DB
|
||||
await sync_single_post(g.s, ghost_post["id"])
|
||||
await g.s.flush()
|
||||
|
||||
# Set user_id on the newly created post
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select
|
||||
local_post = (await g.s.execute(
|
||||
select(Post).where(Post.ghost_id == ghost_post["id"])
|
||||
)).scalar_one_or_none()
|
||||
if local_post and local_post.user_id is None:
|
||||
local_post.user_id = g.user.id
|
||||
await g.s.flush()
|
||||
|
||||
# Clear blog listing cache
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the edit page (post is likely a draft, so public detail would 404)
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"])))
|
||||
# Redirect to the edit page
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/new-page/")
|
||||
@require_admin
|
||||
async def new_page():
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(is_page=True)
|
||||
tctx["is_page"] = True
|
||||
if not is_htmx_request():
|
||||
html = await render_new_post_page(tctx)
|
||||
else:
|
||||
html = await render_new_post_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
@blogs_bp.post("/new-page/")
|
||||
@require_admin
|
||||
async def new_page_save():
|
||||
from .ghost.ghost_posts import create_page
|
||||
from .ghost.lexical_validator import validate_lexical
|
||||
from .ghost.ghost_sync import sync_single_page
|
||||
from services.post_writer import create_page as writer_create_page
|
||||
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip() or "Untitled"
|
||||
@@ -348,53 +284,44 @@ def register(url_prefix, title):
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_new_post_page, render_editor_panel
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages import render_editor_panel
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
|
||||
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
|
||||
tctx["is_page"] = True
|
||||
html = await render_new_post_page(tctx)
|
||||
html = await _render_new_post_page(tctx)
|
||||
return await make_response(html, 400)
|
||||
|
||||
# Create in Ghost (as page)
|
||||
ghost_page = await create_page(
|
||||
# Create directly in db_blog
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
page = await writer_create_page(
|
||||
g.s,
|
||||
title=title,
|
||||
lexical_json=lexical_raw,
|
||||
status=status,
|
||||
user_id=g.user.id,
|
||||
feature_image=feature_image or None,
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
sx_content=sx_content_raw,
|
||||
)
|
||||
|
||||
# Sync to local DB (uses pages endpoint)
|
||||
await sync_single_page(g.s, ghost_page["id"])
|
||||
await g.s.flush()
|
||||
|
||||
# Set user_id on the newly created page
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select
|
||||
local_post = (await g.s.execute(
|
||||
select(Post).where(Post.ghost_id == ghost_page["id"])
|
||||
)).scalar_one_or_none()
|
||||
if local_post and local_post.user_id is None:
|
||||
local_post.user_id = g.user.id
|
||||
await g.s.flush()
|
||||
|
||||
# Clear blog listing cache
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the page admin
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_page["slug"])))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/drafts/")
|
||||
|
||||
@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
|
||||
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):
|
||||
for m in _CARD_MARKER_RE.finditer(str(html)):
|
||||
post_id_str = m.group(1)
|
||||
inner = m.group(2).strip()
|
||||
if inner:
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# suma_browser/webhooks.py
|
||||
# Ghost webhooks — neutered (Phase 1).
|
||||
#
|
||||
# Post/page/author/tag handlers return 204 no-op.
|
||||
# Member webhook remains active (membership sync handled by account service).
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from quart import Blueprint, request, abort, Response, g
|
||||
from quart import Blueprint, request, abort, Response
|
||||
|
||||
from ..ghost.ghost_sync import (
|
||||
sync_single_page,
|
||||
sync_single_post,
|
||||
sync_single_author,
|
||||
sync_single_tag,
|
||||
)
|
||||
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")
|
||||
@@ -32,6 +28,7 @@ def _extract_id(data: dict, key: str) -> str | None:
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.route("/member/", methods=["POST"])
|
||||
async def webhook_member() -> Response:
|
||||
"""Member webhook still active — delegates to account service."""
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
@@ -39,7 +36,6 @@ async def webhook_member() -> Response:
|
||||
if not ghost_id:
|
||||
abort(400, "no member id")
|
||||
|
||||
# Delegate to account service (membership data lives in db_account)
|
||||
from shared.infrastructure.actions import call_action
|
||||
try:
|
||||
await call_action(
|
||||
@@ -52,61 +48,25 @@ async def webhook_member() -> Response:
|
||||
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
# --- Neutered handlers: Ghost no longer writes content ---
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/post/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_post() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "post")
|
||||
if not ghost_id:
|
||||
abort(400, "no post id")
|
||||
|
||||
await sync_single_post(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/page/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_page() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "page")
|
||||
if not ghost_id:
|
||||
abort(400, "no page id")
|
||||
|
||||
await sync_single_page(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/author/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_author() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
|
||||
if not ghost_id:
|
||||
abort(400, "no author id")
|
||||
|
||||
await sync_single_author(g.s, ghost_id)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
@csrf_exempt
|
||||
@ghost_webhooks.post("/tag/")
|
||||
@clear_cache(tag='blog')
|
||||
async def webhook_tag() -> Response:
|
||||
_check_secret(request)
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
ghost_id = _extract_id(data, "tag")
|
||||
if not ghost_id:
|
||||
abort(400, "no tag id")
|
||||
|
||||
await sync_single_tag(g.s, ghost_id)
|
||||
return Response(status=204)
|
||||
|
||||
@@ -1,185 +1,14 @@
|
||||
"""Blog app data endpoints.
|
||||
|
||||
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
|
||||
cross-app callers via the internal data client.
|
||||
All queries are defined in ``blog/queries.sx``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from quart import Blueprint
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.query_blueprint import create_data_blueprint
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("data", __name__, url_prefix="/internal/data")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.get("/<query_name>")
|
||||
async def handle_query(query_name: str):
|
||||
handler = _handlers.get(query_name)
|
||||
if handler is None:
|
||||
return jsonify({"error": "unknown query"}), 404
|
||||
result = await handler()
|
||||
return jsonify(result)
|
||||
|
||||
# --- post-by-slug ---
|
||||
async def _post_by_slug():
|
||||
slug = request.args.get("slug", "")
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return None
|
||||
return dto_to_dict(post)
|
||||
|
||||
_handlers["post-by-slug"] = _post_by_slug
|
||||
|
||||
# --- post-by-id ---
|
||||
async def _post_by_id():
|
||||
post_id = int(request.args.get("id", 0))
|
||||
post = await services.blog.get_post_by_id(g.s, post_id)
|
||||
if not post:
|
||||
return None
|
||||
return dto_to_dict(post)
|
||||
|
||||
_handlers["post-by-id"] = _post_by_id
|
||||
|
||||
# --- posts-by-ids ---
|
||||
async def _posts_by_ids():
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
posts = await services.blog.get_posts_by_ids(g.s, ids)
|
||||
return [dto_to_dict(p) for p in posts]
|
||||
|
||||
_handlers["posts-by-ids"] = _posts_by_ids
|
||||
|
||||
# --- search-posts ---
|
||||
async def _search_posts():
|
||||
query = request.args.get("query", "")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = int(request.args.get("per_page", 10))
|
||||
posts, total = await services.blog.search_posts(g.s, query, page, per_page)
|
||||
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
|
||||
|
||||
_handlers["search-posts"] = _search_posts
|
||||
|
||||
# --- page-config-ensure ---
|
||||
async def _page_config_ensure():
|
||||
"""Get or create a PageConfig for a container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = request.args.get("container_id", type=int)
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
row = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
g.s.add(row)
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
_handlers["page-config-ensure"] = _page_config_ensure
|
||||
|
||||
# --- page-config ---
|
||||
async def _page_config():
|
||||
"""Return a single PageConfig by container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
cid = request.args.get("container_id", type=int)
|
||||
if cid is None:
|
||||
return None
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id == cid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config"] = _page_config
|
||||
|
||||
# --- page-config-by-id ---
|
||||
async def _page_config_by_id():
|
||||
"""Return a single PageConfig by its primary key."""
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
pc_id = request.args.get("id", type=int)
|
||||
if pc_id is None:
|
||||
return None
|
||||
pc = await g.s.get(PageConfig, pc_id)
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config-by-id"] = _page_config_by_id
|
||||
|
||||
# --- page-configs-batch ---
|
||||
async def _page_configs_batch():
|
||||
"""Return PageConfigs for multiple container_ids (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_page_config_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
_handlers["page-configs-batch"] = _page_configs_batch
|
||||
|
||||
bp, _handlers = create_data_blueprint("blog")
|
||||
return bp
|
||||
|
||||
|
||||
def _page_config_dict(pc) -> dict:
|
||||
"""Serialize PageConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,107 +0,0 @@
|
||||
"""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
|
||||
from shared.sexp.jinja_bridge import sexp
|
||||
|
||||
|
||||
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 = list(await get_navigation_tree(g.s))
|
||||
|
||||
# Append Art-DAG as a synthetic nav entry (not a DB MenuNode)
|
||||
class _NavItem:
|
||||
__slots__ = ("slug", "label", "feature_image")
|
||||
def __init__(self, slug, label, feature_image=None):
|
||||
self.slug = slug
|
||||
self.label = label
|
||||
self.feature_image = feature_image
|
||||
|
||||
menu_items.append(_NavItem("artdag", "art-dag"))
|
||||
|
||||
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
|
||||
|
||||
# --- link-card fragment (s-expression rendered) ---
|
||||
def _render_blog_link_card(post, link: str) -> str:
|
||||
"""Render a blog link-card via the ~link-card s-expression component."""
|
||||
published = post.published_at.strftime("%d %b %Y") if post.published_at else None
|
||||
return sexp(
|
||||
'(~link-card :link link :title title :image image'
|
||||
' :icon "fas fa-file-alt" :subtitle excerpt'
|
||||
' :detail published :data-app "blog")',
|
||||
link=link,
|
||||
title=post.title,
|
||||
image=post.feature_image,
|
||||
excerpt=post.custom_excerpt or post.excerpt,
|
||||
published=published,
|
||||
)
|
||||
|
||||
async def _link_card_handler():
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.urls import blog_url
|
||||
|
||||
slug = request.args.get("slug", "")
|
||||
keys_raw = request.args.get("keys", "")
|
||||
|
||||
# Batch mode
|
||||
if keys_raw:
|
||||
slugs = [k.strip() for k in keys_raw.split(",") if k.strip()]
|
||||
parts = []
|
||||
for s in slugs:
|
||||
parts.append(f"<!-- fragment:{s} -->")
|
||||
post = await services.blog.get_post_by_slug(g.s, s)
|
||||
if post:
|
||||
parts.append(_render_blog_link_card(post, blog_url(f"/{post.slug}")))
|
||||
return "\n".join(parts)
|
||||
|
||||
# Single mode
|
||||
if not slug:
|
||||
return ""
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
return ""
|
||||
return _render_blog_link_card(post, blog_url(f"/{post.slug}"))
|
||||
|
||||
_handlers["link-card"] = _link_card_handler
|
||||
|
||||
# Store handlers dict on blueprint so app code can register handlers
|
||||
bp._fragment_handlers = _handlers
|
||||
|
||||
return bp
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, render_template, make_response, request, jsonify, g
|
||||
from quart import Blueprint, make_response, request, jsonify, g, url_for
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from .services.menu_items import (
|
||||
@@ -12,44 +12,217 @@ from .services.menu_items import (
|
||||
search_pages,
|
||||
MenuItemError,
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from markupsafe import escape
|
||||
from shared.sx.helpers import sx_response, render_to_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
|
||||
async def _render_menu_items_list(menu_items):
|
||||
"""Serialize ORM menu items and render via .sx defcomp."""
|
||||
csrf = generate_csrf_token()
|
||||
items = []
|
||||
for item in menu_items:
|
||||
items.append({
|
||||
"feature_image": getattr(item, "feature_image", None),
|
||||
"label": getattr(item, "label", "") or "",
|
||||
"url": getattr(item, "url", "") or "",
|
||||
"sort_order": getattr(item, "position", 0) or 0,
|
||||
"edit_url": url_for("menu_items.edit_menu_item", item_id=item.id),
|
||||
"delete_url": url_for("menu_items.delete_menu_item_route", item_id=item.id),
|
||||
})
|
||||
new_url = url_for("menu_items.new_menu_item")
|
||||
return await render_to_sx("blog-menu-items-content",
|
||||
menu_items=items, new_url=new_url, csrf=csrf)
|
||||
|
||||
|
||||
def _render_menu_item_form(menu_item=None) -> str:
|
||||
"""Render menu item add/edit form."""
|
||||
csrf = generate_csrf_token()
|
||||
search_url = url_for("menu_items.search_pages_route")
|
||||
is_edit = menu_item is not None
|
||||
|
||||
if is_edit:
|
||||
action_url = url_for("menu_items.update_menu_item_route", item_id=menu_item.id)
|
||||
action_attr = f'sx-put="{action_url}"'
|
||||
post_id = str(menu_item.container_id) if menu_item.container_id else ""
|
||||
label = getattr(menu_item, "label", "") or ""
|
||||
slug = getattr(menu_item, "slug", "") or ""
|
||||
fi = getattr(menu_item, "feature_image", None) or ""
|
||||
else:
|
||||
action_url = url_for("menu_items.create_menu_item_route")
|
||||
action_attr = f'sx-post="{action_url}"'
|
||||
post_id = ""
|
||||
label = ""
|
||||
slug = ""
|
||||
fi = ""
|
||||
|
||||
if post_id:
|
||||
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
|
||||
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
|
||||
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
|
||||
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
|
||||
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
|
||||
else:
|
||||
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
|
||||
|
||||
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
|
||||
title = "Edit Menu Item" if is_edit else "Add Menu Item"
|
||||
|
||||
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">{title}</h2>
|
||||
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
|
||||
<i class="fa fa-times"></i></button>
|
||||
</div>
|
||||
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
|
||||
{selected}
|
||||
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
|
||||
sx-include="#selected-post-id"
|
||||
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{csrf}">
|
||||
<div class="flex gap-2 pb-3 border-b">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<i class="fa fa-save"></i> Save</button>
|
||||
<button type="button" onclick="{close_js}"
|
||||
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
|
||||
<input type="text" placeholder="Search for a page... (or leave blank for all)"
|
||||
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
|
||||
sx-target="#page-search-results" sx-swap="innerHTML"
|
||||
name="q" id="page-search-input"
|
||||
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<div id="page-search-results" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('click', function(e) {{
|
||||
var pageOption = e.target.closest('[data-page-id]');
|
||||
if (pageOption) {{
|
||||
var postId = pageOption.dataset.pageId;
|
||||
var postTitle = pageOption.dataset.pageTitle;
|
||||
var postSlug = pageOption.dataset.pageSlug;
|
||||
var postImage = pageOption.dataset.pageImage;
|
||||
document.getElementById('selected-post-id').value = postId;
|
||||
var display = document.getElementById('selected-page-display');
|
||||
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
|
||||
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
|
||||
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
|
||||
display.classList.remove('hidden');
|
||||
document.getElementById('page-search-results').innerHTML = '';
|
||||
}}
|
||||
}});
|
||||
</script>'''
|
||||
return html
|
||||
|
||||
|
||||
async def _render_page_search_results(pages, query, page, has_more) -> str:
|
||||
"""Render page search results."""
|
||||
if not pages and query:
|
||||
return await render_to_sx("page-search-empty", query=query)
|
||||
if not pages:
|
||||
return ""
|
||||
|
||||
items = []
|
||||
for post in pages:
|
||||
items.append(await render_to_sx("page-search-item",
|
||||
id=post.id, title=post.title,
|
||||
slug=post.slug,
|
||||
feature_image=post.feature_image or None))
|
||||
|
||||
sentinel = ""
|
||||
if has_more:
|
||||
search_url = url_for("menu_items.search_pages_route")
|
||||
sentinel = await render_to_sx("page-search-sentinel",
|
||||
url=search_url, query=query,
|
||||
next_page=page + 1)
|
||||
|
||||
items_sx = "(<> " + " ".join(items) + ")"
|
||||
return await render_to_sx("page-search-results",
|
||||
items=SxExpr(items_sx),
|
||||
sentinel=SxExpr(sentinel) if sentinel else None)
|
||||
|
||||
|
||||
async def _render_menu_items_nav_oob(menu_items) -> str:
|
||||
"""Render OOB nav update for menu items."""
|
||||
from quart import request as qrequest
|
||||
|
||||
if not menu_items:
|
||||
return await render_to_sx("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
|
||||
|
||||
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
|
||||
|
||||
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"
|
||||
)
|
||||
nav_button_cls = (
|
||||
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||
f" rounded bg-stone-200 text-black {select_colours} p-3"
|
||||
)
|
||||
|
||||
container_id = "menu-items-container"
|
||||
arrow_cls = f"scrolling-menu-arrow-{container_id}"
|
||||
|
||||
scroll_hs = (
|
||||
f"on load or scroll"
|
||||
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
|
||||
f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}"
|
||||
f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end"
|
||||
)
|
||||
|
||||
item_parts = []
|
||||
for item in menu_items:
|
||||
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
|
||||
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
|
||||
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
|
||||
|
||||
href = f"/{item_slug}/"
|
||||
selected = "true" if item_slug == first_seg else "false"
|
||||
|
||||
img_sx = await render_to_sx("img-or-placeholder", src=fi, alt=label,
|
||||
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
|
||||
if item_slug != "cart":
|
||||
item_parts.append(await render_to_sx("blog-nav-item-link",
|
||||
href=href, hx_get=f"/{item_slug}/", selected=selected,
|
||||
nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label,
|
||||
))
|
||||
else:
|
||||
item_parts.append(await render_to_sx("blog-nav-item-plain",
|
||||
href=href, selected=selected, nav_cls=nav_button_cls,
|
||||
img=SxExpr(img_sx), label=label,
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||
|
||||
return await render_to_sx("scroll-nav-wrapper",
|
||||
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
|
||||
arrow_cls=arrow_cls,
|
||||
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
|
||||
scroll_hs=scroll_hs,
|
||||
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
|
||||
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||
|
||||
def get_menu_items_nav_oob_sync(menu_items):
|
||||
async def get_menu_items_nav_oob_async(menu_items):
|
||||
"""Helper to generate OOB update for root nav menu items"""
|
||||
from sexp.sexp_components import render_menu_items_nav_oob
|
||||
return render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def list_menu_items():
|
||||
"""List all menu items"""
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_menu_items_page, render_menu_items_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
if not is_htmx_request():
|
||||
html = await render_menu_items_page(tctx)
|
||||
else:
|
||||
html = await render_menu_items_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
return await _render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/new/")
|
||||
@require_admin
|
||||
async def new_menu_item():
|
||||
"""Show form to create new menu item"""
|
||||
html = await render_template(
|
||||
"_types/menu_items/_form.html",
|
||||
menu_item=None,
|
||||
)
|
||||
return await make_response(html)
|
||||
return sx_response(_render_menu_item_form())
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@@ -72,10 +245,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
html = await _render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
except MenuItemError as e:
|
||||
return jsonify({"message": str(e), "errors": {}}), 400
|
||||
@@ -88,11 +260,7 @@ def register():
|
||||
if not menu_item:
|
||||
return await make_response("Menu item not found", 404)
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_form.html",
|
||||
menu_item=menu_item,
|
||||
)
|
||||
return await make_response(html)
|
||||
return sx_response(_render_menu_item_form(menu_item=menu_item))
|
||||
|
||||
@bp.put("/<int:item_id>/")
|
||||
@require_admin
|
||||
@@ -115,10 +283,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
html = await _render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
except MenuItemError as e:
|
||||
return jsonify({"message": str(e), "errors": {}}), 400
|
||||
@@ -136,10 +303,9 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
html = await _render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
@bp.get("/pages/search/")
|
||||
@require_admin
|
||||
@@ -152,14 +318,7 @@ def register():
|
||||
pages, total = await search_pages(g.s, query, page, per_page)
|
||||
has_more = (page * per_page) < total
|
||||
|
||||
html = await render_template(
|
||||
"_types/menu_items/_page_search_results.html",
|
||||
pages=pages,
|
||||
query=query,
|
||||
page=page,
|
||||
has_more=has_more,
|
||||
)
|
||||
return await make_response(html)
|
||||
return sx_response(await _render_page_search_results(pages, query, page, has_more))
|
||||
|
||||
@bp.post("/reorder/")
|
||||
@require_admin
|
||||
@@ -183,9 +342,8 @@ def register():
|
||||
|
||||
# Get updated list and nav OOB
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from sexp.sexp_components import render_menu_items_list
|
||||
html = render_menu_items_list(menu_items)
|
||||
nav_oob = get_menu_items_nav_oob_sync(menu_items)
|
||||
return await make_response(html + nav_oob, 200)
|
||||
html = await _render_menu_items_list(menu_items)
|
||||
nav_oob = await get_menu_items_nav_oob_async(menu_items)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
g,
|
||||
@@ -11,58 +10,315 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
from shared.browser.app.authz import require_admin, require_post_author
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from markupsafe import escape
|
||||
from shared.sx.helpers import sx_response, render_to_sx
|
||||
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||
from shared.utils import host_url
|
||||
|
||||
|
||||
def _raw_html_sx(html: str) -> str:
|
||||
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
|
||||
if not html:
|
||||
return ""
|
||||
return "(raw! " + sx_serialize(html) + ")"
|
||||
|
||||
def _post_to_edit_dict(post) -> dict:
|
||||
"""Convert an ORM Post to a dict matching the shape templates expect.
|
||||
|
||||
The templates were written for Ghost Admin API responses, so we mimic
|
||||
that structure (dot-access on dicts via Jinja) from ORM columns.
|
||||
"""
|
||||
d: dict = {}
|
||||
for col in (
|
||||
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
|
||||
"sx_content",
|
||||
"feature_image", "feature_image_alt", "feature_image_caption",
|
||||
"excerpt", "custom_excerpt", "visibility", "status", "featured",
|
||||
"is_page", "email_only", "canonical_url",
|
||||
"meta_title", "meta_description",
|
||||
"og_image", "og_title", "og_description",
|
||||
"twitter_image", "twitter_title", "twitter_description",
|
||||
"custom_template", "reading_time", "comment_id",
|
||||
):
|
||||
d[col] = getattr(post, col, None)
|
||||
|
||||
# Timestamps as ISO strings (templates do [:16] slicing)
|
||||
for ts in ("published_at", "updated_at", "created_at"):
|
||||
val = getattr(post, ts, None)
|
||||
d[ts] = val.isoformat() if val else ""
|
||||
|
||||
# Tags as list of dicts with .name (for Jinja map(attribute='name'))
|
||||
if hasattr(post, "tags") and post.tags:
|
||||
d["tags"] = [{"name": t.name, "slug": t.slug, "id": t.id} for t in post.tags]
|
||||
else:
|
||||
d["tags"] = []
|
||||
|
||||
# email/newsletter — not available without Ghost, set safe defaults
|
||||
d["email"] = None
|
||||
d["newsletter"] = None
|
||||
|
||||
return d
|
||||
|
||||
|
||||
async def _render_features(features, post, result):
|
||||
"""Render features panel via .sx defcomp."""
|
||||
slug = post.get("slug", "")
|
||||
return await render_to_sx("blog-features-panel-content",
|
||||
features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)),
|
||||
calendar_checked=bool(features.get("calendar")),
|
||||
market_checked=bool(features.get("market")),
|
||||
show_sumup=bool(features.get("calendar") or features.get("market")),
|
||||
sumup_url=host_url(url_for("blog.post.admin.update_sumup", slug=slug)),
|
||||
merchant_code=result.get("sumup_merchant_code") or "",
|
||||
placeholder="\u2022" * 8 if result.get("sumup_configured") else "sup_sk_...",
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
)
|
||||
|
||||
|
||||
def _serialize_markets(markets, slug):
|
||||
"""Serialize ORM/DTO market objects to dicts for .sx defcomp."""
|
||||
result = []
|
||||
for m in markets:
|
||||
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
||||
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
|
||||
result.append({
|
||||
"name": m_name, "slug": m_slug,
|
||||
"delete_url": host_url(url_for("blog.post.admin.delete_market",
|
||||
slug=slug, market_slug=m_slug)),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _render_calendar_view(
|
||||
calendar, year, month, month_name, weekday_names, weeks,
|
||||
prev_month, prev_month_year, next_month, next_month_year,
|
||||
prev_year, next_year, month_entries, associated_entry_ids,
|
||||
post_slug: str,
|
||||
) -> str:
|
||||
"""Build calendar month grid HTML."""
|
||||
from quart import url_for as qurl
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
esc = escape
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
cal_id = calendar.id
|
||||
|
||||
def cal_url(y, m):
|
||||
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
|
||||
|
||||
cur_url = cal_url(year, month)
|
||||
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
|
||||
|
||||
nav = (
|
||||
f'<header class="flex items-center justify-center mb-4">'
|
||||
f'<nav class="flex items-center gap-2 text-xl">'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">«</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">‹</a>'
|
||||
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">›</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">»</a>'
|
||||
f'</nav></header>'
|
||||
)
|
||||
|
||||
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
|
||||
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
|
||||
|
||||
cells: list[str] = []
|
||||
for week in weeks:
|
||||
for day in week:
|
||||
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
|
||||
day_date = day.date
|
||||
|
||||
entry_btns: list[str] = []
|
||||
for e in month_entries:
|
||||
e_start = getattr(e, "start_at", None)
|
||||
if not e_start or e_start.date() != day_date:
|
||||
continue
|
||||
e_id = getattr(e, "id", None)
|
||||
e_name = esc(getattr(e, "name", ""))
|
||||
t_url = toggle_url_fn(e_id)
|
||||
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
|
||||
|
||||
if e_id in associated_entry_ids:
|
||||
entry_btns.append(
|
||||
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
|
||||
f'<span class="truncate flex-1">{e_name}</span>'
|
||||
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
|
||||
f' data-confirm data-confirm-title="Remove entry?"'
|
||||
f' data-confirm-text="Remove {e_name} from this post?"'
|
||||
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><i class="fa fa-times"></i></button></div>'
|
||||
)
|
||||
else:
|
||||
entry_btns.append(
|
||||
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
|
||||
f' data-confirm data-confirm-title="Add entry?"'
|
||||
f' data-confirm-text="Add {e_name} to this post?"'
|
||||
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><span class="truncate block">{e_name}</span></button>'
|
||||
)
|
||||
|
||||
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
|
||||
cells.append(
|
||||
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
|
||||
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
|
||||
)
|
||||
|
||||
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
|
||||
|
||||
html = (
|
||||
f'<div id="calendar-view-{cal_id}"'
|
||||
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
|
||||
f'{nav}'
|
||||
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
async def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||
"""Render the associated entries panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for as qurl
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
has_entries = False
|
||||
entry_items: list[str] = []
|
||||
for calendar in all_calendars:
|
||||
entries = getattr(calendar, "entries", []) or []
|
||||
cal_name = getattr(calendar, "name", "")
|
||||
cal_post = getattr(calendar, "post", None)
|
||||
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
|
||||
cal_title = getattr(cal_post, "title", "") if cal_post else ""
|
||||
|
||||
for entry in entries:
|
||||
e_id = getattr(entry, "id", None)
|
||||
if e_id not in associated_entry_ids:
|
||||
continue
|
||||
if getattr(entry, "deleted_at", None) is not None:
|
||||
continue
|
||||
has_entries = True
|
||||
e_name = getattr(entry, "name", "")
|
||||
e_start = getattr(entry, "start_at", None)
|
||||
e_end = getattr(entry, "end_at", None)
|
||||
|
||||
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
|
||||
|
||||
img_sx = await render_to_sx("blog-entry-image", src=cal_fi, title=cal_title)
|
||||
|
||||
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
|
||||
if e_end:
|
||||
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||
|
||||
entry_items.append(await render_to_sx("blog-associated-entry",
|
||||
confirm_text=f"This will remove {e_name} from this post",
|
||||
toggle_url=toggle_url,
|
||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
img=SxExpr(img_sx), name=e_name,
|
||||
date_str=f"{cal_name} \u2022 {date_str}",
|
||||
))
|
||||
|
||||
if has_entries:
|
||||
content_sx = await render_to_sx("blog-associated-entries-content",
|
||||
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
|
||||
)
|
||||
else:
|
||||
content_sx = await render_to_sx("blog-associated-entries-empty")
|
||||
|
||||
return await render_to_sx("blog-associated-entries-panel", content=SxExpr(content_sx))
|
||||
|
||||
|
||||
async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||
"""Render the OOB nav entries swap."""
|
||||
entries_list = []
|
||||
if associated_entries and hasattr(associated_entries, "entries"):
|
||||
entries_list = associated_entries.entries or []
|
||||
|
||||
has_items = bool(entries_list or calendars)
|
||||
|
||||
if not has_items:
|
||||
return await render_to_sx("blog-nav-entries-empty")
|
||||
|
||||
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"
|
||||
)
|
||||
nav_cls = (
|
||||
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||
f" rounded bg-stone-200 text-black {select_colours} p-2"
|
||||
)
|
||||
|
||||
post_slug = post.get("slug", "")
|
||||
|
||||
scroll_hs = (
|
||||
"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"
|
||||
)
|
||||
|
||||
item_parts = []
|
||||
|
||||
for entry in entries_list:
|
||||
e_name = getattr(entry, "name", "")
|
||||
e_start = getattr(entry, "start_at", None)
|
||||
e_end = getattr(entry, "end_at", None)
|
||||
cal_slug = getattr(entry, "calendar_slug", "")
|
||||
|
||||
if e_start:
|
||||
entry_path = (
|
||||
f"/{post_slug}/{cal_slug}/"
|
||||
f"{e_start.year}/{e_start.month}/{e_start.day}"
|
||||
f"/entries/{getattr(entry, 'id', '')}/"
|
||||
)
|
||||
date_str = e_start.strftime("%b %d, %Y at %H:%M")
|
||||
if e_end:
|
||||
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||
else:
|
||||
entry_path = f"/{post_slug}/{cal_slug}/"
|
||||
date_str = ""
|
||||
|
||||
item_parts.append(await render_to_sx("calendar-entry-nav",
|
||||
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
|
||||
))
|
||||
|
||||
for calendar in (calendars or []):
|
||||
cal_name = getattr(calendar, "name", "")
|
||||
cal_slug = getattr(calendar, "slug", "")
|
||||
cal_path = f"/{post_slug}/{cal_slug}/"
|
||||
|
||||
item_parts.append(await render_to_sx("blog-nav-calendar-item",
|
||||
href=cal_path, nav_cls=nav_cls, name=cal_name,
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||
|
||||
return await render_to_sx("scroll-nav-wrapper",
|
||||
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
|
||||
arrow_cls="entries-nav-arrow",
|
||||
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
|
||||
scroll_hs=scroll_hs,
|
||||
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
|
||||
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(slug: str):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# Load features for page admin (page_configs now lives in db_blog)
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
sumup_merchant_code = ""
|
||||
sumup_checkout_prefix = ""
|
||||
if post.get("is_page"):
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||
|
||||
ctx = {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
"sumup_merchant_code": sumup_merchant_code,
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_admin_page, render_post_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
html = await render_post_admin_page(tctx)
|
||||
else:
|
||||
html = await render_post_admin_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@bp.put("/features/")
|
||||
@require_admin
|
||||
async def update_features(slug: str):
|
||||
@@ -97,15 +353,8 @@ def register():
|
||||
})
|
||||
|
||||
features = result.get("features", {})
|
||||
|
||||
from sexp.sexp_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
)
|
||||
return await make_response(html)
|
||||
html = await _render_features(features, post, result)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.put("/admin/sumup/")
|
||||
@require_admin
|
||||
@@ -137,30 +386,8 @@ def register():
|
||||
result = await call_action("blog", "update-page-config", payload=payload)
|
||||
|
||||
features = result.get("features", {})
|
||||
from sexp.sexp_components import render_features_panel
|
||||
html = render_features_panel(
|
||||
features, post,
|
||||
sumup_configured=result.get("sumup_configured", False),
|
||||
sumup_merchant_code=result.get("sumup_merchant_code") or "",
|
||||
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/data/")
|
||||
@require_admin
|
||||
async def data(slug: str):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_data_page, render_post_data_oob
|
||||
|
||||
data_html = await render_template("_types/post_data/_main_panel.html")
|
||||
tctx = await get_template_context()
|
||||
tctx["data_html"] = data_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_data_page(tctx)
|
||||
else:
|
||||
html = await render_post_data_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
html = await _render_features(features, post, result)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||
@require_admin
|
||||
@@ -226,64 +453,15 @@ def register():
|
||||
|
||||
# Get associated entry IDs for this post
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_calendar_view.html",
|
||||
calendar=calendar_obj,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
month_entries=month_entries,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
html = _render_calendar_view(
|
||||
calendar_obj, year, month, month_name, weekday_names, weeks,
|
||||
prev_month, prev_month_year, next_month, next_month_year,
|
||||
prev_year, next_year, month_entries, associated_entry_ids,
|
||||
g.post_data["post"]["slug"],
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/entries/")
|
||||
@require_admin
|
||||
async def entries(slug: str):
|
||||
from ..services.entry_associations import get_post_entry_ids
|
||||
from shared.models.calendars import Calendar
|
||||
from sqlalchemy import select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
||||
|
||||
# Load ALL calendars (not just this post's calendars)
|
||||
result = await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
all_calendars = result.scalars().all()
|
||||
|
||||
# Load entries and post for each calendar
|
||||
for calendar in all_calendars:
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_entries_page, render_post_entries_oob
|
||||
|
||||
entries_html = await render_template(
|
||||
"_types/post_entries/_main_panel.html",
|
||||
all_calendars=all_calendars,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["entries_html"] = entries_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_entries_page(tctx)
|
||||
else:
|
||||
html = await render_post_entries_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.post("/entries/<int:entry_id>/toggle/")
|
||||
@require_admin
|
||||
@@ -294,7 +472,7 @@ def register():
|
||||
from quart import jsonify
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
|
||||
is_associated, error = await toggle_entry_association(post_id, entry_id)
|
||||
|
||||
if error:
|
||||
return jsonify({"message": error, "errors": {}}), 400
|
||||
@@ -302,7 +480,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
# Return updated association status
|
||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
# Load ALL calendars
|
||||
result = await g.s.execute(
|
||||
@@ -317,7 +495,7 @@ def register():
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
|
||||
# Fetch associated entries for nav display
|
||||
associated_entries = await get_associated_entries(g.s, post_id)
|
||||
associated_entries = await get_associated_entries(post_id)
|
||||
|
||||
# Load calendars for this post (for nav display)
|
||||
calendars = (
|
||||
@@ -329,50 +507,19 @@ def register():
|
||||
).scalars().all()
|
||||
|
||||
# Return the associated entries admin list + OOB update for nav entries
|
||||
from sexp.sexp_components import render_associated_entries, render_nav_entries_oob
|
||||
|
||||
post = g.post_data["post"]
|
||||
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
|
||||
admin_list = await _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||
nav_entries_html = await _render_nav_entries_oob(associated_entries, calendars, post)
|
||||
|
||||
return await make_response(admin_list + nav_entries_html)
|
||||
|
||||
@bp.get("/settings/")
|
||||
@require_post_author
|
||||
async def settings(slug: str):
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
|
||||
ghost_id = g.post_data["post"]["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"
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_settings_page, render_post_settings_oob
|
||||
|
||||
settings_html = await render_template(
|
||||
"_types/post_settings/_main_panel.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["settings_html"] = settings_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_settings_page(tctx)
|
||||
else:
|
||||
html = await render_post_settings_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
return sx_response(admin_list + nav_entries_html)
|
||||
|
||||
@bp.post("/settings/")
|
||||
@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, sync_single_page
|
||||
from services.post_writer import update_post_settings, OptimisticLockError
|
||||
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"))
|
||||
post_id = g.post_data["post"]["id"]
|
||||
form = await request.form
|
||||
|
||||
updated_at = form.get("updated_at", "")
|
||||
@@ -405,89 +552,48 @@ def register():
|
||||
kwargs["featured"] = form.get("featured") == "on"
|
||||
kwargs["email_only"] = form.get("email_only") == "on"
|
||||
|
||||
# Tags — comma-separated string → list of {"name": "..."} dicts
|
||||
# Tags — comma-separated string → list of names
|
||||
tags_str = form.get("tags", "").strip()
|
||||
if tags_str:
|
||||
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
|
||||
else:
|
||||
kwargs["tags"] = []
|
||||
tag_names = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
|
||||
|
||||
# Update in Ghost
|
||||
await update_post_settings(
|
||||
ghost_id=ghost_id,
|
||||
updated_at=updated_at,
|
||||
is_page=is_page,
|
||||
**kwargs,
|
||||
)
|
||||
try:
|
||||
post = await update_post_settings(
|
||||
g.s,
|
||||
post_id=post_id,
|
||||
expected_updated_at=updated_at,
|
||||
tag_names=tag_names,
|
||||
**kwargs,
|
||||
)
|
||||
except OptimisticLockError:
|
||||
from urllib.parse import quote
|
||||
return redirect(
|
||||
host_url(url_for("defpage_post_settings", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
# Clear caches
|
||||
await invalidate_tag_cache("blog")
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_post_author
|
||||
async def edit(slug: str):
|
||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
ghost_id = g.post_data["post"]["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"
|
||||
save_error = request.args.get("error", "")
|
||||
|
||||
# Newsletters live in db_account — fetch via HTTP
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
# Convert dicts to objects with .name/.ghost_id attributes for template compat
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_edit_page, render_post_edit_oob
|
||||
|
||||
edit_html = await render_template(
|
||||
"_types/post_edit/_main_panel.html",
|
||||
ghost_post=ghost_post,
|
||||
save_success=save_success,
|
||||
save_error=save_error,
|
||||
newsletters=newsletters,
|
||||
)
|
||||
tctx = await get_template_context()
|
||||
tctx["edit_html"] = edit_html
|
||||
if not is_htmx_request():
|
||||
html = await render_post_edit_page(tctx)
|
||||
else:
|
||||
html = await render_post_edit_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
# Redirect using the (possibly new) slug
|
||||
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.post("/edit/")
|
||||
@require_post_author
|
||||
async def edit_save(slug: str):
|
||||
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, sync_single_page
|
||||
from services.post_writer import update_post as writer_update, OptimisticLockError
|
||||
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"))
|
||||
post_id = g.post_data["post"]["id"]
|
||||
form = await request.form
|
||||
title = form.get("title", "").strip()
|
||||
lexical_raw = form.get("lexical", "")
|
||||
updated_at = form.get("updated_at", "")
|
||||
status = form.get("status", "draft")
|
||||
publish_mode = form.get("publish_mode", "web")
|
||||
newsletter_slug = form.get("newsletter_slug", "").strip() or None
|
||||
feature_image = form.get("feature_image", "").strip()
|
||||
custom_excerpt = form.get("custom_excerpt", "").strip()
|
||||
feature_image_caption = form.get("feature_image_caption", "").strip()
|
||||
@@ -497,82 +603,63 @@ def register():
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Update in Ghost (content save — no status change yet)
|
||||
ghost_post = await update_post(
|
||||
ghost_id=ghost_id,
|
||||
lexical_json=lexical_raw,
|
||||
title=title or None,
|
||||
updated_at=updated_at,
|
||||
feature_image=feature_image,
|
||||
custom_excerpt=custom_excerpt,
|
||||
feature_image_caption=feature_image_caption,
|
||||
is_page=is_page,
|
||||
)
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Publish workflow
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
publish_requested_msg = None
|
||||
|
||||
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
|
||||
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
|
||||
if already_emailed and publish_mode in ("email", "both"):
|
||||
publish_mode = "web"
|
||||
# Determine effective status
|
||||
effective_status: str | None = None
|
||||
current_status = g.post_data["post"].get("status", "draft")
|
||||
|
||||
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
|
||||
# Non-admin requesting publish: don't send status to Ghost, set local flag
|
||||
if status == "published" and current_status != "published" and not is_admin:
|
||||
# Non-admin requesting publish: keep as draft, set local flag
|
||||
publish_requested_msg = "Publish requested — an admin will review."
|
||||
elif status and status != ghost_post.get("status"):
|
||||
# Status is changing — determine email params based on publish_mode
|
||||
email_kwargs: dict = {}
|
||||
if status == "published" and publish_mode in ("email", "both") and newsletter_slug:
|
||||
email_kwargs["newsletter_slug"] = newsletter_slug
|
||||
email_kwargs["email_segment"] = "all"
|
||||
if publish_mode == "email":
|
||||
email_kwargs["email_only"] = True
|
||||
elif status and status != current_status:
|
||||
effective_status = status
|
||||
|
||||
from ...blog.ghost.ghost_posts import update_post as _up
|
||||
ghost_post = await _up(
|
||||
ghost_id=ghost_id,
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
# Build optional kwargs — only pass sx_content if the form field was present
|
||||
extra_kw: dict = {}
|
||||
if "sx_content" in form:
|
||||
extra_kw["sx_content"] = sx_content_raw
|
||||
try:
|
||||
post = await writer_update(
|
||||
g.s,
|
||||
post_id=post_id,
|
||||
lexical_json=lexical_raw,
|
||||
title=None,
|
||||
updated_at=ghost_post["updated_at"],
|
||||
status=status,
|
||||
is_page=is_page,
|
||||
**email_kwargs,
|
||||
title=title or None,
|
||||
expected_updated_at=updated_at,
|
||||
feature_image=feature_image or None,
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
status=effective_status,
|
||||
**extra_kw,
|
||||
)
|
||||
except OptimisticLockError:
|
||||
return redirect(
|
||||
host_url(url_for("defpage_post_edit", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
# Sync to local DB
|
||||
if is_page:
|
||||
await sync_single_page(g.s, ghost_id)
|
||||
else:
|
||||
await sync_single_post(g.s, ghost_id)
|
||||
# Handle publish_requested flag
|
||||
if publish_requested_msg:
|
||||
post.publish_requested = True
|
||||
elif status == "published" and is_admin:
|
||||
post.publish_requested = False
|
||||
await g.s.flush()
|
||||
|
||||
# Handle publish_requested flag on the local post
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
local_post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.ghost_id == ghost_id)
|
||||
)).scalar_one_or_none()
|
||||
if local_post:
|
||||
if publish_requested_msg:
|
||||
local_post.publish_requested = True
|
||||
elif status == "published" and is_admin:
|
||||
local_post.publish_requested = False
|
||||
await g.s.flush()
|
||||
|
||||
# Clear caches
|
||||
await invalidate_tag_cache("blog")
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
|
||||
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
|
||||
# Redirect to GET (PRG pattern) — use post.slug in case it changed
|
||||
redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
|
||||
if publish_requested_msg:
|
||||
redirect_url += "&publish_requested=1"
|
||||
return redirect(redirect_url)
|
||||
@@ -597,9 +684,11 @@ def register():
|
||||
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sexp.sexp_components import render_markets_panel
|
||||
html = render_markets_panel(page_markets, post)
|
||||
return await make_response(html)
|
||||
slug = post.get("slug", "")
|
||||
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||
html = await render_to_sx("blog-markets-panel-content",
|
||||
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.post("/markets/new/")
|
||||
@require_admin
|
||||
@@ -624,9 +713,11 @@ def register():
|
||||
# Return updated markets list
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sexp.sexp_components import render_markets_panel
|
||||
html = render_markets_panel(page_markets, post)
|
||||
return await make_response(html)
|
||||
slug = post.get("slug", "")
|
||||
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||
html = await render_to_sx("blog-markets-panel-content",
|
||||
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.delete("/markets/<market_slug>/")
|
||||
@require_admin
|
||||
@@ -645,8 +736,10 @@ def register():
|
||||
# Return updated markets list
|
||||
page_markets = await _fetch_page_markets(post_id)
|
||||
|
||||
from sexp.sexp_components import render_markets_panel
|
||||
html = render_markets_panel(page_markets, post)
|
||||
return await make_response(html)
|
||||
slug = post.get("slug", "")
|
||||
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
|
||||
html = await render_to_sx("blog-markets-panel-content",
|
||||
markets=_serialize_markets(page_markets, slug), create_url=create_url)
|
||||
return sx_response(html)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -21,6 +21,7 @@ from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
from .admin.routes import register as register_admin
|
||||
from shared.config import config
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
def register():
|
||||
bp = Blueprint("post", __name__, url_prefix='/<slug>')
|
||||
@@ -70,7 +71,7 @@ def register():
|
||||
post_slug = (g.post_data.get("post") or {}).get("slug", "")
|
||||
|
||||
# Fetch container nav from relations service
|
||||
container_nav_html = await fetch_fragment("relations", "container-nav", params={
|
||||
container_nav = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(db_post_id),
|
||||
"post_slug": post_slug,
|
||||
@@ -79,7 +80,7 @@ def register():
|
||||
ctx = {
|
||||
**p_data,
|
||||
"base_title": config()["title"],
|
||||
"container_nav_html": container_nav_html,
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
|
||||
# Page cart badge via HTTP
|
||||
@@ -103,30 +104,69 @@ def register():
|
||||
@bp.get("/")
|
||||
@cache_page(tag="post.post_detail")
|
||||
async def post_detail(slug: str):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_post_page, render_post_oob
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import (
|
||||
render_to_sx, root_header_sx, full_page_sx, oob_page_sx,
|
||||
post_header_sx, oob_header_sx, mobile_menu_sx,
|
||||
post_mobile_nav_sx, mobile_root_nav_sx,
|
||||
)
|
||||
from shared.services.registry import services
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.utils import host_url
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_post_page(tctx)
|
||||
else:
|
||||
html = await render_post_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
# Render post content via .sx defcomp
|
||||
post = tctx.get("post") or {}
|
||||
user = getattr(g, "user", None)
|
||||
rights = tctx.get("rights") or {}
|
||||
blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/")
|
||||
csrf = generate_csrf_token()
|
||||
svc = services.get("blog_page")
|
||||
detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base)
|
||||
content = await render_to_sx("blog-post-detail-content", **detail_data)
|
||||
meta_data = svc.post_meta_data(post, tctx.get("base_title", ""))
|
||||
meta = await render_to_sx("blog-meta", **meta_data)
|
||||
|
||||
if not is_htmx_request():
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
|
||||
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
return await make_response(html)
|
||||
else:
|
||||
root_hdr = await root_header_sx(tctx)
|
||||
post_hdr = await post_header_sx(tctx)
|
||||
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
|
||||
sx_src = await oob_page_sx(oobs=header_oob, content=content, menu=
|
||||
mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)))
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/like/toggle/")
|
||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||
async def like_toggle(slug: str):
|
||||
from shared.utils import host_url
|
||||
from sexp.sexp_components import render_like_toggle_button
|
||||
from shared.sx.helpers import render_to_sx
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
async def _like_btn(liked):
|
||||
if liked:
|
||||
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post"
|
||||
else:
|
||||
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post"
|
||||
return await render_to_sx("market-like-toggle-button",
|
||||
colour=colour, action=like_url,
|
||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
label=label, icon_cls=icon)
|
||||
|
||||
# Get post_id from g.post_data
|
||||
if not g.user:
|
||||
html = render_like_toggle_button(slug, False, like_url)
|
||||
resp = make_response(html, 403)
|
||||
return resp
|
||||
return sx_response(await _like_btn(False), status=403)
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
user_id = g.user.id
|
||||
@@ -134,10 +174,8 @@ def register():
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "post", "target_id": post_id,
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
html = render_like_toggle_button(slug, liked, like_url)
|
||||
return html
|
||||
return sx_response(await _like_btn(result["liked"]))
|
||||
|
||||
@bp.get("/w/<widget_domain>/")
|
||||
async def widget_paginate(slug: str, widget_domain: str):
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.contracts.dtos import MarketPlaceDTO
|
||||
from shared.infrastructure.actions import call_action, ActionError
|
||||
from shared.services.registry import services
|
||||
from services import blog_service
|
||||
|
||||
|
||||
class MarketError(ValueError):
|
||||
@@ -33,7 +33,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
raise MarketError("Market name must not be empty.")
|
||||
slug = slugify(name)
|
||||
|
||||
post = await services.blog.get_post_by_id(sess, post_id)
|
||||
post = await blog_service.get_post_by_id(sess, post_id)
|
||||
if not post:
|
||||
raise MarketError(f"Post {post_id} does not exist.")
|
||||
|
||||
@@ -57,7 +57,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
|
||||
|
||||
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
post = await blog_service.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,56 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, make_response, request, g, abort
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from quart import Blueprint, request, g, abort
|
||||
|
||||
from shared.browser.app.authz import require_login
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response, render_to_sx
|
||||
from models import Snippet
|
||||
|
||||
|
||||
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
|
||||
|
||||
|
||||
async def _visible_snippets(session):
|
||||
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
|
||||
rows = (await session.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
|
||||
return rows
|
||||
async def _render_snippets():
|
||||
"""Render snippets list via service data + .sx defcomp."""
|
||||
from shared.services.registry import services
|
||||
data = await services.get("blog_page").snippets_data(g.s)
|
||||
return await render_to_sx("blog-snippets-content", **data)
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
|
||||
|
||||
@bp.get("/")
|
||||
@require_login
|
||||
async def list_snippets():
|
||||
"""List snippets visible to the current user."""
|
||||
snippets = await _visible_snippets(g.s)
|
||||
is_admin = g.rights.get("admin")
|
||||
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_snippets_page, render_snippets_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = snippets
|
||||
tctx["is_admin"] = is_admin
|
||||
if not is_htmx_request():
|
||||
html = await render_snippets_page(tctx)
|
||||
else:
|
||||
html = await render_snippets_oob(tctx)
|
||||
|
||||
return await make_response(html)
|
||||
|
||||
@bp.delete("/<int:snippet_id>/")
|
||||
@require_login
|
||||
async def delete_snippet(snippet_id: int):
|
||||
@@ -66,10 +35,7 @@ def register():
|
||||
await g.s.delete(snippet)
|
||||
await g.s.flush()
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
from sexp.sexp_components import render_snippets_list
|
||||
html = render_snippets_list(snippets, is_admin)
|
||||
return await make_response(html)
|
||||
return sx_response(await _render_snippets())
|
||||
|
||||
@bp.patch("/<int:snippet_id>/visibility/")
|
||||
@require_login
|
||||
@@ -91,9 +57,6 @@ def register():
|
||||
snippet.visibility = visibility
|
||||
await g.s.flush()
|
||||
|
||||
snippets = await _visible_snippets(g.s)
|
||||
from sexp.sexp_components import render_snippets_list
|
||||
html = render_snippets_list(snippets, True)
|
||||
return await make_response(html)
|
||||
return sx_response(await _render_snippets())
|
||||
|
||||
return bp
|
||||
|
||||
@@ -54,6 +54,7 @@ fi
|
||||
RELOAD_FLAG=""
|
||||
if [[ "${RELOAD:-}" == "true" ]]; then
|
||||
RELOAD_FLAG="--reload"
|
||||
python3 -m shared.dev_watcher &
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
|
||||
else
|
||||
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
|
||||
from .content import Post, Author, Tag, PostAuthor, PostTag, PostUser
|
||||
from .snippet import Snippet
|
||||
from .tag_group import TagGroup, TagGroupTag
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
||||
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||
|
||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -50,8 +50,8 @@ class Post(Base):
|
||||
__tablename__ = "posts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
||||
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, server_default=func.gen_random_uuid())
|
||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
@@ -60,6 +60,7 @@ class Post(Base):
|
||||
plaintext: Mapped[Optional[str]] = mapped_column(Text())
|
||||
mobiledoc: Mapped[Optional[str]] = mapped_column(Text())
|
||||
lexical: Mapped[Optional[str]] = mapped_column(Text())
|
||||
sx_content: Mapped[Optional[str]] = mapped_column(Text())
|
||||
|
||||
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
||||
feature_image_alt: Mapped[Optional[str]] = mapped_column(Text())
|
||||
@@ -89,8 +90,8 @@ class Post(Base):
|
||||
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
|
||||
|
||||
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
@@ -136,7 +137,7 @@ class Author(Base):
|
||||
__tablename__ = "authors"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
||||
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||
|
||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -192,3 +193,15 @@ class PostTag(Base):
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
|
||||
class PostUser(Base):
|
||||
"""Multi-author M2M: links posts to users (cross-DB, no FK on user_id)."""
|
||||
__tablename__ = "post_users"
|
||||
|
||||
post_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("posts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, index=True,
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
@@ -1,3 +1,3 @@
|
||||
from shared.models.ghost_content import ( # noqa: F401
|
||||
Tag, Post, Author, PostAuthor, PostTag,
|
||||
from .content import ( # noqa: F401
|
||||
Tag, Post, Author, PostAuthor, PostTag, PostUser,
|
||||
)
|
||||
|
||||
38
blog/queries.sx
Normal file
38
blog/queries.sx
Normal file
@@ -0,0 +1,38 @@
|
||||
;; Blog service — inter-service data queries
|
||||
|
||||
(defquery post-by-slug (&key slug)
|
||||
"Fetch a single blog post by its URL slug."
|
||||
(service "blog" "get-post-by-slug" :slug slug))
|
||||
|
||||
(defquery post-by-id (&key id)
|
||||
"Fetch a single blog post by its primary key."
|
||||
(service "blog" "get-post-by-id" :id id))
|
||||
|
||||
(defquery posts-by-ids (&key ids)
|
||||
"Fetch multiple blog posts by comma-separated IDs."
|
||||
(service "blog" "get-posts-by-ids" :ids (split-ids ids)))
|
||||
|
||||
(defquery search-posts (&key query page per-page)
|
||||
"Search blog posts by text query, paginated."
|
||||
(let ((result (service "blog" "search-posts"
|
||||
:query query :page page :per-page per-page)))
|
||||
{"posts" (nth result 0) "total" (nth result 1)}))
|
||||
|
||||
(defquery page-config-ensure (&key container-type container-id)
|
||||
"Get or create a PageConfig for a container."
|
||||
(service "page-config" "ensure"
|
||||
:container-type container-type :container-id container-id))
|
||||
|
||||
(defquery page-config (&key container-type container-id)
|
||||
"Return a single PageConfig by container type + id."
|
||||
(service "page-config" "get-by-container"
|
||||
:container-type container-type :container-id container-id))
|
||||
|
||||
(defquery page-config-by-id (&key id)
|
||||
"Return a single PageConfig by primary key."
|
||||
(service "page-config" "get-by-id" :id id))
|
||||
|
||||
(defquery page-configs-batch (&key container-type ids)
|
||||
"Return PageConfigs for multiple container IDs (comma-separated)."
|
||||
(service "page-config" "get-batch"
|
||||
:container-type container-type :ids (split-ids ids)))
|
||||
68
blog/scripts/backfill_sx_content.py
Normal file
68
blog/scripts/backfill_sx_content.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill sx_content from lexical JSON for all posts that have lexical but no sx_content.
|
||||
|
||||
Usage:
|
||||
python -m blog.scripts.backfill_sx_content [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def backfill(dry_run: bool = False) -> int:
|
||||
from shared.db.session import get_session
|
||||
from models.ghost_content import Post
|
||||
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||
|
||||
converted = 0
|
||||
errors = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
stmt = select(Post).where(
|
||||
and_(
|
||||
Post.lexical.isnot(None),
|
||||
Post.lexical != "",
|
||||
(Post.sx_content.is_(None)) | (Post.sx_content == ""),
|
||||
)
|
||||
)
|
||||
result = await sess.execute(stmt)
|
||||
posts = result.scalars().all()
|
||||
|
||||
print(f"Found {len(posts)} posts to convert")
|
||||
|
||||
for post in posts:
|
||||
try:
|
||||
sx = lexical_to_sx(post.lexical)
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] {post.slug}: {len(sx)} chars")
|
||||
else:
|
||||
post.sx_content = sx
|
||||
print(f" Converted: {post.slug} ({len(sx)} chars)")
|
||||
converted += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
if not dry_run:
|
||||
await sess.commit()
|
||||
|
||||
print(f"\nDone: {converted} converted, {errors} errors")
|
||||
return converted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Backfill sx_content from lexical JSON")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Don't write to database")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(backfill(dry_run=args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
469
blog/scripts/final_ghost_sync.py
Normal file
469
blog/scripts/final_ghost_sync.py
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Final Ghost → db_blog sync with HTML verification + author→user migration.
|
||||
|
||||
Run once before cutting over to native writes (Phase 1).
|
||||
|
||||
Usage:
|
||||
cd blog && python -m scripts.final_ghost_sync
|
||||
|
||||
Requires GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY, DATABASE_URL,
|
||||
and DATABASE_URL_ACCOUNT env vars.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, func, delete
|
||||
|
||||
# Ensure project root is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "shared"))
|
||||
|
||||
from shared.db.base import Base # noqa: E402
|
||||
from shared.db.session import get_session, get_account_session, _engine # noqa: E402
|
||||
from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt # noqa: E402
|
||||
from blog.models.content import Post, Author, Tag, PostUser, PostAuthor # noqa: E402
|
||||
from shared.models.user import User # noqa: E402
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
log = logging.getLogger("final_ghost_sync")
|
||||
|
||||
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
|
||||
|
||||
|
||||
def _auth_header() -> dict[str, str]:
|
||||
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = re.sub(r"[^\w\s-]", "", s)
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ghost API fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fetch_all(endpoint: str) -> list[dict]:
|
||||
"""Fetch all items from a Ghost Admin API endpoint."""
|
||||
url = (
|
||||
f"{GHOST_ADMIN_API_URL}/{endpoint}/"
|
||||
"?include=authors,tags&limit=all"
|
||||
"&formats=html,plaintext,mobiledoc,lexical"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(url, headers=_auth_header())
|
||||
resp.raise_for_status()
|
||||
key = endpoint # "posts" or "pages"
|
||||
return resp.json().get(key, [])
|
||||
|
||||
|
||||
async def fetch_all_ghost_content() -> list[dict]:
|
||||
"""Fetch all posts + pages from Ghost."""
|
||||
posts, pages = await asyncio.gather(
|
||||
_fetch_all("posts"),
|
||||
_fetch_all("pages"),
|
||||
)
|
||||
for p in pages:
|
||||
p["page"] = True
|
||||
return posts + pages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Author → User migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def migrate_authors_to_users(author_bucket: dict[str, dict]) -> dict[str, int]:
|
||||
"""Ensure every Ghost author has a corresponding User in db_account.
|
||||
|
||||
Returns mapping of ghost_author_id → user_id.
|
||||
"""
|
||||
ghost_id_to_user_id: dict[str, int] = {}
|
||||
|
||||
async with get_account_session() as sess:
|
||||
async with sess.begin():
|
||||
for ghost_author_id, ga in author_bucket.items():
|
||||
email = (ga.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
log.warning(
|
||||
"Author %s (%s) has no email — skipping user creation",
|
||||
ghost_author_id, ga.get("name"),
|
||||
)
|
||||
continue
|
||||
|
||||
# Find existing user by email
|
||||
user = (await sess.execute(
|
||||
select(User).where(User.email == email)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
# Auto-create user for this Ghost author
|
||||
user = User(
|
||||
email=email,
|
||||
name=ga.get("name"),
|
||||
slug=ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0]),
|
||||
bio=ga.get("bio"),
|
||||
profile_image=ga.get("profile_image"),
|
||||
cover_image=ga.get("cover_image"),
|
||||
website=ga.get("website"),
|
||||
location=ga.get("location"),
|
||||
facebook=ga.get("facebook"),
|
||||
twitter=ga.get("twitter"),
|
||||
is_admin=True, # Ghost authors are admins
|
||||
)
|
||||
sess.add(user)
|
||||
await sess.flush()
|
||||
log.info("Created user %d for author %s (%s)", user.id, ga.get("name"), email)
|
||||
else:
|
||||
# Update profile fields from Ghost author (fill in blanks)
|
||||
if not user.slug:
|
||||
user.slug = ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0])
|
||||
if not user.name and ga.get("name"):
|
||||
user.name = ga["name"]
|
||||
if not user.bio and ga.get("bio"):
|
||||
user.bio = ga["bio"]
|
||||
if not user.profile_image and ga.get("profile_image"):
|
||||
user.profile_image = ga["profile_image"]
|
||||
if not user.cover_image and ga.get("cover_image"):
|
||||
user.cover_image = ga["cover_image"]
|
||||
if not user.website and ga.get("website"):
|
||||
user.website = ga["website"]
|
||||
if not user.location and ga.get("location"):
|
||||
user.location = ga["location"]
|
||||
if not user.facebook and ga.get("facebook"):
|
||||
user.facebook = ga["facebook"]
|
||||
if not user.twitter and ga.get("twitter"):
|
||||
user.twitter = ga["twitter"]
|
||||
await sess.flush()
|
||||
log.info("Updated user %d profile from author %s", user.id, ga.get("name"))
|
||||
|
||||
ghost_id_to_user_id[ghost_author_id] = user.id
|
||||
|
||||
return ghost_id_to_user_id
|
||||
|
||||
|
||||
async def populate_post_users(
|
||||
data: list[dict],
|
||||
ghost_author_to_user: dict[str, int],
|
||||
) -> int:
|
||||
"""Populate post_users M2M and set user_id on all posts from author mapping.
|
||||
|
||||
Returns number of post_users rows created.
|
||||
"""
|
||||
rows_created = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
async with sess.begin():
|
||||
for gp in data:
|
||||
ghost_post_id = gp["id"]
|
||||
post = (await sess.execute(
|
||||
select(Post).where(Post.ghost_id == ghost_post_id)
|
||||
)).scalar_one_or_none()
|
||||
if not post:
|
||||
continue
|
||||
|
||||
# Set primary user_id from primary author
|
||||
pa = gp.get("primary_author")
|
||||
if pa and pa["id"] in ghost_author_to_user:
|
||||
post.user_id = ghost_author_to_user[pa["id"]]
|
||||
|
||||
# Build post_users from all authors
|
||||
await sess.execute(
|
||||
delete(PostUser).where(PostUser.post_id == post.id)
|
||||
)
|
||||
seen_user_ids: set[int] = set()
|
||||
for idx, a in enumerate(gp.get("authors") or []):
|
||||
uid = ghost_author_to_user.get(a["id"])
|
||||
if uid and uid not in seen_user_ids:
|
||||
seen_user_ids.add(uid)
|
||||
sess.add(PostUser(post_id=post.id, user_id=uid, sort_order=idx))
|
||||
rows_created += 1
|
||||
|
||||
await sess.flush()
|
||||
|
||||
return rows_created
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content sync (reuse ghost_sync upsert logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def run_sync() -> dict:
|
||||
"""Run full Ghost content sync and author→user migration."""
|
||||
from bp.blog.ghost.ghost_sync import (
|
||||
_upsert_author,
|
||||
_upsert_tag,
|
||||
_upsert_post,
|
||||
)
|
||||
|
||||
log.info("Fetching all content from Ghost...")
|
||||
data = await fetch_all_ghost_content()
|
||||
log.info("Received %d posts/pages from Ghost", len(data))
|
||||
|
||||
# Collect authors and tags
|
||||
author_bucket: dict[str, dict] = {}
|
||||
tag_bucket: dict[str, dict] = {}
|
||||
|
||||
for p in data:
|
||||
for a in p.get("authors") or []:
|
||||
author_bucket[a["id"]] = a
|
||||
if p.get("primary_author"):
|
||||
author_bucket[p["primary_author"]["id"]] = p["primary_author"]
|
||||
for t in p.get("tags") or []:
|
||||
tag_bucket[t["id"]] = t
|
||||
if p.get("primary_tag"):
|
||||
tag_bucket[p["primary_tag"]["id"]] = p["primary_tag"]
|
||||
|
||||
# Step 1: Upsert content into db_blog (existing Ghost sync logic)
|
||||
async with get_session() as sess:
|
||||
async with sess.begin():
|
||||
author_map: dict[str, Author] = {}
|
||||
for ga in author_bucket.values():
|
||||
a = await _upsert_author(sess, ga)
|
||||
author_map[ga["id"]] = a
|
||||
log.info("Upserted %d authors (legacy table)", len(author_map))
|
||||
|
||||
tag_map: dict[str, Tag] = {}
|
||||
for gt in tag_bucket.values():
|
||||
t = await _upsert_tag(sess, gt)
|
||||
tag_map[gt["id"]] = t
|
||||
log.info("Upserted %d tags", len(tag_map))
|
||||
|
||||
for gp in data:
|
||||
await _upsert_post(sess, gp, author_map, tag_map)
|
||||
log.info("Upserted %d posts/pages", len(data))
|
||||
|
||||
# Step 2: Migrate authors → users in db_account
|
||||
log.info("")
|
||||
log.info("--- Migrating Ghost authors → Users ---")
|
||||
ghost_author_to_user = await migrate_authors_to_users(author_bucket)
|
||||
log.info("Mapped %d Ghost authors to User records", len(ghost_author_to_user))
|
||||
|
||||
# Step 3: Populate post_users M2M and set user_id
|
||||
log.info("")
|
||||
log.info("--- Populating post_users M2M ---")
|
||||
pu_rows = await populate_post_users(data, ghost_author_to_user)
|
||||
log.info("Created %d post_users rows", pu_rows)
|
||||
|
||||
n_posts = sum(1 for p in data if not p.get("page"))
|
||||
n_pages = sum(1 for p in data if p.get("page"))
|
||||
|
||||
return {
|
||||
"posts": n_posts,
|
||||
"pages": n_pages,
|
||||
"authors": len(author_bucket),
|
||||
"tags": len(tag_bucket),
|
||||
"users_mapped": len(ghost_author_to_user),
|
||||
"post_users_rows": pu_rows,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML rendering verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_html(html: str | None) -> str:
|
||||
if not html:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", html.strip())
|
||||
|
||||
|
||||
async def verify_html_rendering() -> dict:
|
||||
"""Re-render all posts from lexical and compare with stored HTML."""
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
import json
|
||||
|
||||
diffs_found = 0
|
||||
posts_checked = 0
|
||||
posts_no_lexical = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
result = await sess.execute(
|
||||
select(Post).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.status == "published",
|
||||
)
|
||||
)
|
||||
posts = result.scalars().all()
|
||||
|
||||
for post in posts:
|
||||
if not post.lexical:
|
||||
posts_no_lexical += 1
|
||||
continue
|
||||
|
||||
posts_checked += 1
|
||||
try:
|
||||
rendered = render_lexical(json.loads(post.lexical))
|
||||
except Exception as e:
|
||||
log.error(
|
||||
"Render failed for post %d (%s): %s",
|
||||
post.id, post.slug, e,
|
||||
)
|
||||
diffs_found += 1
|
||||
continue
|
||||
|
||||
ghost_html = _normalize_html(post.html)
|
||||
our_html = _normalize_html(rendered)
|
||||
|
||||
if ghost_html != our_html:
|
||||
diffs_found += 1
|
||||
diff = difflib.unified_diff(
|
||||
ghost_html.splitlines(keepends=True),
|
||||
our_html.splitlines(keepends=True),
|
||||
fromfile=f"ghost/{post.slug}",
|
||||
tofile=f"rendered/{post.slug}",
|
||||
n=2,
|
||||
)
|
||||
diff_text = "".join(diff)
|
||||
if len(diff_text) > 2000:
|
||||
diff_text = diff_text[:2000] + "\n... (truncated)"
|
||||
log.warning(
|
||||
"HTML diff for post %d (%s):\n%s",
|
||||
post.id, post.slug, diff_text,
|
||||
)
|
||||
|
||||
return {
|
||||
"checked": posts_checked,
|
||||
"no_lexical": posts_no_lexical,
|
||||
"diffs": diffs_found,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def run_verification() -> dict:
|
||||
async with get_session() as sess:
|
||||
total_posts = await sess.scalar(
|
||||
select(func.count(Post.id)).where(Post.deleted_at.is_(None))
|
||||
)
|
||||
null_html = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.status == "published",
|
||||
Post.html.is_(None),
|
||||
)
|
||||
)
|
||||
null_lexical = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.status == "published",
|
||||
Post.lexical.is_(None),
|
||||
)
|
||||
)
|
||||
# Posts with user_id set
|
||||
has_user = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.user_id.isnot(None),
|
||||
)
|
||||
)
|
||||
no_user = await sess.scalar(
|
||||
select(func.count(Post.id)).where(
|
||||
Post.deleted_at.is_(None),
|
||||
Post.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_posts": total_posts,
|
||||
"published_null_html": null_html,
|
||||
"published_null_lexical": null_lexical,
|
||||
"posts_with_user": has_user,
|
||||
"posts_without_user": no_user,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def main():
|
||||
log.info("=" * 60)
|
||||
log.info("Final Ghost Sync — Cutover Preparation")
|
||||
log.info("=" * 60)
|
||||
|
||||
# Step 1: Full sync + author→user migration
|
||||
log.info("")
|
||||
log.info("--- Step 1: Full sync from Ghost + author→user migration ---")
|
||||
stats = await run_sync()
|
||||
log.info(
|
||||
"Sync complete: %d posts, %d pages, %d authors, %d tags, %d users mapped",
|
||||
stats["posts"], stats["pages"], stats["authors"], stats["tags"],
|
||||
stats["users_mapped"],
|
||||
)
|
||||
|
||||
# Step 2: Verification queries
|
||||
log.info("")
|
||||
log.info("--- Step 2: Verification queries ---")
|
||||
vq = await run_verification()
|
||||
log.info("Total non-deleted posts/pages: %d", vq["total_posts"])
|
||||
log.info("Published with NULL html: %d", vq["published_null_html"])
|
||||
log.info("Published with NULL lexical: %d", vq["published_null_lexical"])
|
||||
log.info("Posts with user_id: %d", vq["posts_with_user"])
|
||||
log.info("Posts WITHOUT user_id: %d", vq["posts_without_user"])
|
||||
|
||||
if vq["published_null_html"] > 0:
|
||||
log.warning("WARN: Some published posts have no HTML!")
|
||||
if vq["published_null_lexical"] > 0:
|
||||
log.warning("WARN: Some published posts have no Lexical JSON!")
|
||||
if vq["posts_without_user"] > 0:
|
||||
log.warning("WARN: Some posts have no user_id — authors may lack email!")
|
||||
|
||||
# Step 3: HTML rendering verification
|
||||
log.info("")
|
||||
log.info("--- Step 3: HTML rendering verification ---")
|
||||
html_stats = await verify_html_rendering()
|
||||
log.info(
|
||||
"Checked %d posts, %d with diffs, %d without lexical",
|
||||
html_stats["checked"], html_stats["diffs"], html_stats["no_lexical"],
|
||||
)
|
||||
|
||||
if html_stats["diffs"] > 0:
|
||||
log.warning(
|
||||
"WARN: %d posts have HTML rendering differences — "
|
||||
"review diffs above before cutover",
|
||||
html_stats["diffs"],
|
||||
)
|
||||
|
||||
# Summary
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info("SUMMARY")
|
||||
log.info(" Posts synced: %d", stats["posts"])
|
||||
log.info(" Pages synced: %d", stats["pages"])
|
||||
log.info(" Authors synced: %d", stats["authors"])
|
||||
log.info(" Tags synced: %d", stats["tags"])
|
||||
log.info(" Users mapped: %d", stats["users_mapped"])
|
||||
log.info(" post_users rows: %d", stats["post_users_rows"])
|
||||
log.info(" HTML diffs: %d", html_stats["diffs"])
|
||||
log.info(" Published null HTML: %d", vq["published_null_html"])
|
||||
log.info(" Published null lex: %d", vq["published_null_lexical"])
|
||||
log.info(" Posts without user: %d", vq["posts_without_user"])
|
||||
log.info("=" * 60)
|
||||
|
||||
if (html_stats["diffs"] == 0
|
||||
and vq["published_null_html"] == 0
|
||||
and vq["posts_without_user"] == 0):
|
||||
log.info("All checks passed — safe to proceed with cutover.")
|
||||
else:
|
||||
log.warning("Review warnings above before proceeding.")
|
||||
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
89
blog/scripts/migrate_sx_html.py
Normal file
89
blog/scripts/migrate_sx_html.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Re-convert sx_content from lexical JSON to eliminate ~kg-html wrappers and
|
||||
raw caption strings.
|
||||
|
||||
The updated lexical_to_sx converter now produces native sx expressions instead
|
||||
of (1) wrapping HTML/markdown cards in (~kg-html :html "...") and (2) storing
|
||||
captions as escaped HTML strings. This script re-runs the conversion on all
|
||||
posts that already have sx_content, overwriting the old output.
|
||||
|
||||
Usage:
|
||||
cd blog && python3 scripts/migrate_sx_html.py [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
|
||||
async def migrate(dry_run: bool = False) -> int:
|
||||
from shared.db.session import get_session
|
||||
from models.ghost_content import Post
|
||||
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||
|
||||
converted = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
async with get_session() as sess:
|
||||
# All posts with lexical content (whether or not sx_content exists)
|
||||
stmt = select(Post).where(
|
||||
and_(
|
||||
Post.lexical.isnot(None),
|
||||
Post.lexical != "",
|
||||
)
|
||||
)
|
||||
result = await sess.execute(stmt)
|
||||
posts = result.scalars().all()
|
||||
|
||||
print(f"Found {len(posts)} posts with lexical content")
|
||||
|
||||
for post in posts:
|
||||
try:
|
||||
new_sx = lexical_to_sx(post.lexical)
|
||||
if post.sx_content == new_sx:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
old_has_kg = "~kg-html" in (post.sx_content or "")
|
||||
old_has_raw = "raw! caption" in (post.sx_content or "")
|
||||
markers = []
|
||||
if old_has_kg:
|
||||
markers.append("~kg-html")
|
||||
if old_has_raw:
|
||||
markers.append("raw-caption")
|
||||
tag = f" [{', '.join(markers)}]" if markers else ""
|
||||
print(f" [DRY RUN] {post.slug}: {len(new_sx)} chars{tag}")
|
||||
else:
|
||||
post.sx_content = new_sx
|
||||
print(f" Converted: {post.slug} ({len(new_sx)} chars)")
|
||||
converted += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
if not dry_run and converted:
|
||||
await sess.commit()
|
||||
|
||||
print(f"\nDone: {converted} converted, {skipped} unchanged, {errors} errors")
|
||||
return converted
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Re-convert sx_content to eliminate ~kg-html and raw captions"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Preview changes without writing to database")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(migrate(dry_run=args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,69 @@
|
||||
"""Blog app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.contracts.dtos import PostDTO
|
||||
|
||||
from models.content import Post
|
||||
|
||||
|
||||
def _post_to_dto(post: Post) -> PostDTO:
|
||||
return PostDTO(
|
||||
id=post.id,
|
||||
slug=post.slug,
|
||||
title=post.title,
|
||||
status=post.status,
|
||||
visibility=post.visibility,
|
||||
is_page=post.is_page,
|
||||
feature_image=post.feature_image,
|
||||
html=post.html,
|
||||
sx_content=post.sx_content,
|
||||
excerpt=post.excerpt,
|
||||
custom_excerpt=post.custom_excerpt,
|
||||
published_at=post.published_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlBlogService:
|
||||
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
|
||||
post = (
|
||||
await session.execute(select(Post).where(Post.slug == slug))
|
||||
).scalar_one_or_none()
|
||||
return _post_to_dto(post) if post else None
|
||||
|
||||
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
|
||||
post = (
|
||||
await session.execute(select(Post).where(Post.id == id))
|
||||
).scalar_one_or_none()
|
||||
return _post_to_dto(post) if post else None
|
||||
|
||||
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
|
||||
if not ids:
|
||||
return []
|
||||
result = await session.execute(select(Post).where(Post.id.in_(ids)))
|
||||
return [_post_to_dto(p) for p in result.scalars().all()]
|
||||
|
||||
async def search_posts(
|
||||
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
|
||||
) -> tuple[list[PostDTO], int]:
|
||||
if query:
|
||||
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
|
||||
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
|
||||
else:
|
||||
count_stmt = select(func.count(Post.id))
|
||||
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
|
||||
|
||||
total = (await session.execute(count_stmt)).scalar() or 0
|
||||
offset = (page - 1) * per_page
|
||||
result = await session.execute(posts_stmt.limit(per_page).offset(offset))
|
||||
return [_post_to_dto(p) for p in result.scalars().all()], total
|
||||
|
||||
|
||||
# Module-level singleton — import this in blog code.
|
||||
blog_service = SqlBlogService()
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the blog app.
|
||||
@@ -9,11 +72,15 @@ def register_domain_services() -> None:
|
||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||
"""
|
||||
from shared.services.registry import services
|
||||
from shared.services.blog_impl import SqlBlogService
|
||||
services.register("blog", blog_service)
|
||||
|
||||
services.blog = SqlBlogService()
|
||||
from shared.services.page_config_impl import SqlPageConfigService
|
||||
services.register("page_config", SqlPageConfigService())
|
||||
|
||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||
if not services.has("federation"):
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
services.federation = SqlFederationService()
|
||||
|
||||
from .blog_page import BlogPageService
|
||||
services.register("blog_page", BlogPageService())
|
||||
|
||||
465
blog/services/blog_page.py
Normal file
465
blog/services/blog_page.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""Blog page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class BlogPageService:
|
||||
"""Service for blog page data, callable via (service "blog-page" ...)."""
|
||||
|
||||
async def cache_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
return {
|
||||
"clear_url": qurl("settings.cache_clear"),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def snippets_data(self, session, **kw):
|
||||
from quart import g, url_for as qurl
|
||||
from sqlalchemy import select, or_
|
||||
from models import Snippet
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
csrf = generate_csrf_token()
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
rows = (await session.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
|
||||
snippets = []
|
||||
for s in rows:
|
||||
s_id = s.id
|
||||
s_vis = s.visibility or "private"
|
||||
s_uid = s.user_id
|
||||
owner = "You" if s_uid == uid else f"User #{s_uid}"
|
||||
can_delete = s_uid == uid or is_admin
|
||||
d = {
|
||||
"id": s_id,
|
||||
"name": s.name or "",
|
||||
"visibility": s_vis,
|
||||
"owner": owner,
|
||||
"can_delete": can_delete,
|
||||
}
|
||||
if is_admin:
|
||||
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
|
||||
if can_delete:
|
||||
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
|
||||
snippets.append(d)
|
||||
return {
|
||||
"snippets": snippets,
|
||||
"is_admin": bool(is_admin),
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def menu_items_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from bp.menu_items.services.menu_items import get_all_menu_items
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
menu_items = await get_all_menu_items(session)
|
||||
csrf = generate_csrf_token()
|
||||
items = []
|
||||
for mi in menu_items:
|
||||
i_id = mi.id
|
||||
label = mi.label or ""
|
||||
fi = getattr(mi, "feature_image", None)
|
||||
sort = mi.position or 0
|
||||
items.append({
|
||||
"id": i_id,
|
||||
"label": label,
|
||||
"url": mi.url or "",
|
||||
"sort_order": sort,
|
||||
"feature_image": fi,
|
||||
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
|
||||
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
|
||||
})
|
||||
return {
|
||||
"menu_items": items,
|
||||
"new_url": qurl("menu_items.new_menu_item"),
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def tag_groups_data(self, session, **kw):
|
||||
from quart import url_for as qurl
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup
|
||||
from bp.blog.admin.routes import _unassigned_tags
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
groups_rows = list(
|
||||
(await session.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(session)
|
||||
|
||||
groups = []
|
||||
for g in groups_rows:
|
||||
groups.append({
|
||||
"id": g.id,
|
||||
"name": g.name or "",
|
||||
"slug": getattr(g, "slug", "") or "",
|
||||
"feature_image": getattr(g, "feature_image", None),
|
||||
"colour": getattr(g, "colour", None),
|
||||
"sort_order": getattr(g, "sort_order", 0) or 0,
|
||||
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
|
||||
})
|
||||
|
||||
unassigned_tags = []
|
||||
for t in unassigned:
|
||||
unassigned_tags.append({
|
||||
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"unassigned_tags": unassigned_tags,
|
||||
"create_url": qurl("blog.tag_groups_admin.create"),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def tag_group_edit_data(self, session, *, id=None, **kw):
|
||||
from quart import abort, url_for as qurl
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
tg = await session.get(TagGroup, id)
|
||||
if not tg:
|
||||
abort(404)
|
||||
|
||||
assigned_rows = list(
|
||||
(await session.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
assigned_set = set(assigned_rows)
|
||||
|
||||
all_tags_rows = list(
|
||||
(await session.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
|
||||
all_tags = []
|
||||
for t in all_tags_rows:
|
||||
all_tags.append({
|
||||
"id": t.id,
|
||||
"name": getattr(t, "name", "") or "",
|
||||
"feature_image": getattr(t, "feature_image", None),
|
||||
"checked": t.id in assigned_set,
|
||||
})
|
||||
|
||||
return {
|
||||
"group": {
|
||||
"id": tg.id,
|
||||
"name": tg.name or "",
|
||||
"colour": getattr(tg, "colour", "") or "",
|
||||
"sort_order": getattr(tg, "sort_order", 0) or 0,
|
||||
"feature_image": getattr(tg, "feature_image", "") or "",
|
||||
},
|
||||
"all_tags": all_tags,
|
||||
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
|
||||
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
|
||||
"csrf": generate_csrf_token(),
|
||||
}
|
||||
|
||||
async def index_data(self, session, **kw):
|
||||
"""Blog index page data — posts or pages listing with filters."""
|
||||
from quart import g, request, url_for as qurl
|
||||
from bp.blog.services.posts_data import posts_data
|
||||
from bp.blog.services.pages_data import pages_data
|
||||
from bp.blog.filters.qs import decode
|
||||
from shared.utils import host_url
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
q = decode()
|
||||
content_type = request.args.get("type", "posts")
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
user = getattr(g, "user", None)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/")
|
||||
|
||||
if content_type == "pages":
|
||||
data = await pages_data(session, q.page, q.search)
|
||||
posts_list = data.get("pages", [])
|
||||
tag_groups_raw = []
|
||||
authors_raw = []
|
||||
draft_count = 0
|
||||
selected_tags = ()
|
||||
selected_authors = ()
|
||||
selected_groups = ()
|
||||
else:
|
||||
show_drafts = bool(q.drafts and user)
|
||||
drafts_user_id = None if (not show_drafts or is_admin) else user.id
|
||||
count_drafts_uid = None if (user and is_admin) else (user.id if user else False)
|
||||
data = await posts_data(
|
||||
session, q.page, q.search, q.sort, q.selected_tags,
|
||||
q.selected_authors, q.liked,
|
||||
drafts=show_drafts, drafts_user_id=drafts_user_id,
|
||||
count_drafts_for_user_id=count_drafts_uid,
|
||||
selected_groups=q.selected_groups,
|
||||
)
|
||||
posts_list = data.get("posts", [])
|
||||
tag_groups_raw = data.get("tag_groups", [])
|
||||
authors_raw = data.get("authors", [])
|
||||
draft_count = data.get("draft_count", 0)
|
||||
selected_tags = q.selected_tags
|
||||
selected_authors = q.selected_authors
|
||||
selected_groups = q.selected_groups
|
||||
|
||||
page_num = data.get("page", q.page)
|
||||
total_pages = data.get("total_pages", 1)
|
||||
card_widgets = data.get("card_widgets_html", {})
|
||||
|
||||
current_local_href = f"{blog_url_base}/index"
|
||||
if content_type == "pages":
|
||||
current_local_href += "?type=pages"
|
||||
hx_select = "#main-panel"
|
||||
|
||||
# Serialize posts for cards
|
||||
def _format_ts(dt):
|
||||
if not dt:
|
||||
return ""
|
||||
return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)
|
||||
|
||||
cards = []
|
||||
for p in posts_list:
|
||||
slug = p.get("slug", "")
|
||||
href = f"{blog_url_base}/{slug}/"
|
||||
status = p.get("status", "published")
|
||||
is_draft = status == "draft"
|
||||
ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at"))
|
||||
tags = []
|
||||
for t in (p.get("tags") or []):
|
||||
name = t.get("name") or getattr(t, "name", "")
|
||||
fi = t.get("feature_image") or getattr(t, "feature_image", None)
|
||||
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
|
||||
authors = []
|
||||
for a in (p.get("authors") or []):
|
||||
name = a.get("name") or getattr(a, "name", "")
|
||||
img = a.get("profile_image") or getattr(a, "profile_image", None)
|
||||
authors.append({"name": name, "image": img or ""})
|
||||
card = {
|
||||
"slug": slug, "href": href, "hx_select": hx_select,
|
||||
"title": p.get("title", ""), "feature_image": p.get("feature_image"),
|
||||
"excerpt": p.get("custom_excerpt") or p.get("excerpt", ""),
|
||||
"is_draft": is_draft,
|
||||
"publish_requested": p.get("publish_requested", False) if is_draft else False,
|
||||
"status_timestamp": ts,
|
||||
"tags": tags, "authors": authors,
|
||||
"has_like": bool(user),
|
||||
}
|
||||
if user:
|
||||
card["liked"] = p.get("is_liked", False)
|
||||
card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/"
|
||||
card["csrf_token"] = csrf
|
||||
widget = card_widgets.get(str(p.get("id", "")), "")
|
||||
if widget:
|
||||
card["widget"] = widget
|
||||
# Page-specific fields
|
||||
features = p.get("features") or {}
|
||||
if content_type == "pages":
|
||||
card["has_calendar"] = features.get("calendar", False)
|
||||
card["has_market"] = features.get("market", False)
|
||||
card["pub_timestamp"] = ts
|
||||
cards.append(card)
|
||||
|
||||
# Serialize tag groups for filter
|
||||
tag_groups = []
|
||||
for grp in tag_groups_raw:
|
||||
g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "")
|
||||
g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "")
|
||||
g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None)
|
||||
g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None)
|
||||
g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0)
|
||||
if g_count <= 0 and g_slug not in selected_groups:
|
||||
continue
|
||||
tag_groups.append({
|
||||
"slug": g_slug, "name": g_name, "feature_image": g_fi,
|
||||
"colour": g_colour, "post_count": g_count,
|
||||
"is_selected": g_slug in selected_groups,
|
||||
})
|
||||
|
||||
# Serialize authors for filter
|
||||
authors_list = []
|
||||
for a in authors_raw:
|
||||
a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "")
|
||||
a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "")
|
||||
a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None)
|
||||
a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0)
|
||||
authors_list.append({
|
||||
"slug": a_slug, "name": a_name, "profile_image": a_img,
|
||||
"published_post_count": a_count,
|
||||
"is_selected": a_slug in selected_authors,
|
||||
})
|
||||
|
||||
# Filter summary names
|
||||
tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]]
|
||||
au_summary_names = [a["name"] for a in authors_list if a["is_selected"]]
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"view": q.view,
|
||||
"cards": cards,
|
||||
"page": page_num,
|
||||
"total_pages": total_pages,
|
||||
"current_local_href": current_local_href,
|
||||
"hx_select": hx_select,
|
||||
"is_admin": is_admin,
|
||||
"has_user": bool(user),
|
||||
"draft_count": draft_count,
|
||||
"drafts": bool(q.drafts) if user else False,
|
||||
"new_post_href": f"{blog_url_base}/new/",
|
||||
"new_page_href": f"{blog_url_base}/new-page/",
|
||||
"tag_groups": tag_groups,
|
||||
"authors": authors_list,
|
||||
"is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0,
|
||||
"is_any_author": len(selected_authors) == 0,
|
||||
"tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "",
|
||||
"au_summary": ", ".join(au_summary_names) if au_summary_names else "",
|
||||
"blog_url_base": blog_url_base,
|
||||
"csrf": csrf,
|
||||
}
|
||||
|
||||
async def post_admin_data(self, session, *, slug=None, **kw):
|
||||
"""Post admin panel — just needs post loaded into context."""
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# _ensure_post_data is called by before_request in defpage context
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
if post.get("is_page"):
|
||||
pc = (await session.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
return {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
}
|
||||
|
||||
def post_meta_data(self, post, base_title):
|
||||
"""Compute SEO meta tag values from post dict."""
|
||||
import re
|
||||
from quart import request as req
|
||||
|
||||
is_public = post.get("visibility") == "public"
|
||||
is_published = post.get("status") == "published"
|
||||
email_only = post.get("email_only", False)
|
||||
robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
|
||||
|
||||
desc = (post.get("meta_description") or post.get("og_description") or
|
||||
post.get("twitter_description") or post.get("custom_excerpt") or
|
||||
post.get("excerpt") or "")
|
||||
if not desc and post.get("html"):
|
||||
desc = re.sub(r'<[^>]+>', '', post["html"])
|
||||
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
|
||||
|
||||
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
|
||||
canonical = post.get("canonical_url") or (req.url if req else "")
|
||||
|
||||
post_title = post.get("meta_title") or post.get("title") or ""
|
||||
page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
|
||||
og_title = post.get("og_title") or page_title
|
||||
tw_title = post.get("twitter_title") or page_title
|
||||
is_article = not post.get("is_page")
|
||||
|
||||
return {
|
||||
"robots": robots, "page_title": page_title, "desc": desc,
|
||||
"canonical": canonical,
|
||||
"og_type": "article" if is_article else "website",
|
||||
"og_title": og_title, "image": image,
|
||||
"twitter_card": "summary_large_image" if image else "summary",
|
||||
"twitter_title": tw_title,
|
||||
}
|
||||
|
||||
def post_detail_data(self, post, user, rights, csrf, blog_url_base):
|
||||
"""Serialize post detail view data for ~blog-post-detail-content defcomp."""
|
||||
slug = post.get("slug", "")
|
||||
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
user_id = getattr(user, "id", None) if user else None
|
||||
|
||||
# Tags and authors
|
||||
tags = []
|
||||
for t in (post.get("tags") or []):
|
||||
name = t.get("name") or getattr(t, "name", "")
|
||||
fi = t.get("feature_image") or getattr(t, "feature_image", None)
|
||||
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
|
||||
authors = []
|
||||
for a in (post.get("authors") or []):
|
||||
name = a.get("name") or getattr(a, "name", "")
|
||||
img = a.get("profile_image") or getattr(a, "profile_image", None)
|
||||
authors.append({"name": name, "image": img or ""})
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"is_draft": post.get("status") == "draft",
|
||||
"publish_requested": post.get("publish_requested", False),
|
||||
"can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id),
|
||||
"edit_href": f"{blog_url_base}/{slug}/admin/edit/",
|
||||
"is_page": bool(post.get("is_page")),
|
||||
"has_user": bool(user),
|
||||
"liked": post.get("is_liked", False),
|
||||
"like_url": f"{blog_url_base}/{slug}/like/toggle/",
|
||||
"csrf": csrf,
|
||||
"custom_excerpt": post.get("custom_excerpt") or "",
|
||||
"tags": tags,
|
||||
"authors": authors,
|
||||
"feature_image": post.get("feature_image"),
|
||||
"html_content": post.get("html", ""),
|
||||
"sx_content": post.get("sx_content", ""),
|
||||
}
|
||||
|
||||
async def preview_data(self, session, *, slug=None, **kw):
|
||||
"""Build preview data with prettified/rendered content."""
|
||||
from quart import g
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await session.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
result = {}
|
||||
sx_content = getattr(post, "sx_content", None) or ""
|
||||
if sx_content:
|
||||
from shared.sx.prettify import sx_to_pretty_sx
|
||||
result["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||
lexical_raw = getattr(post, "lexical", None) or ""
|
||||
if lexical_raw:
|
||||
from shared.sx.prettify import json_to_pretty_sx
|
||||
result["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||
if sx_content:
|
||||
from shared.sx.parser import parse as sx_parse
|
||||
from shared.sx.html import render as sx_html_render
|
||||
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||
try:
|
||||
parsed = sx_parse(sx_content)
|
||||
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||
except Exception:
|
||||
result["sx_rendered"] = "<em>Error rendering sx</em>"
|
||||
if lexical_raw:
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
try:
|
||||
result["lex_rendered"] = render_lexical(lexical_raw)
|
||||
except Exception:
|
||||
result["lex_rendered"] = "<em>Error rendering lexical</em>"
|
||||
return result
|
||||
465
blog/services/post_writer.py
Normal file
465
blog/services/post_writer.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""Native post/page CRUD — replaces Ghost Admin API writes.
|
||||
|
||||
All operations go directly to db_blog. Ghost is never called.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import nh3
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.ghost_content import Post, Tag, PostTag, PostUser
|
||||
from shared.browser.app.utils import utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = re.sub(r"[^\w\s-]", "", s)
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def _reading_time(plaintext: str | None) -> int:
|
||||
"""Estimate reading time in minutes (word count / 265, min 1)."""
|
||||
if not plaintext:
|
||||
return 0
|
||||
words = len(plaintext.split())
|
||||
return max(1, round(words / 265))
|
||||
|
||||
|
||||
def _extract_plaintext(html: str) -> str:
|
||||
"""Strip HTML tags to get plaintext."""
|
||||
text = re.sub(r"<[^>]+>", "", html)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def _sanitize_html(html: str | None) -> str | None:
|
||||
if not html:
|
||||
return html
|
||||
return nh3.clean(
|
||||
html,
|
||||
tags={
|
||||
"a", "abbr", "acronym", "b", "blockquote", "br", "code",
|
||||
"div", "em", "figcaption", "figure", "h1", "h2", "h3",
|
||||
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p",
|
||||
"pre", "span", "strong", "sub", "sup", "table", "tbody",
|
||||
"td", "th", "thead", "tr", "ul", "video", "source",
|
||||
"picture", "iframe", "audio",
|
||||
},
|
||||
attributes={
|
||||
"*": {"class", "id", "style"},
|
||||
"a": {"href", "title", "target"},
|
||||
"img": {"src", "alt", "title", "width", "height", "loading"},
|
||||
"video": {"src", "controls", "width", "height", "poster"},
|
||||
"audio": {"src", "controls"},
|
||||
"source": {"src", "type"},
|
||||
"iframe": {"src", "width", "height", "frameborder", "allowfullscreen"},
|
||||
"td": {"colspan", "rowspan"},
|
||||
"th": {"colspan", "rowspan"},
|
||||
},
|
||||
link_rel="noopener noreferrer",
|
||||
url_schemes={"http", "https", "mailto"},
|
||||
)
|
||||
|
||||
|
||||
def _render_and_extract(lexical_json: str) -> tuple[str, str, int]:
|
||||
"""Render HTML from Lexical JSON, extract plaintext, compute reading time.
|
||||
|
||||
Returns (html, plaintext, reading_time).
|
||||
"""
|
||||
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||
|
||||
doc = json.loads(lexical_json) if isinstance(lexical_json, str) else lexical_json
|
||||
html = render_lexical(doc)
|
||||
html = _sanitize_html(html)
|
||||
plaintext = _extract_plaintext(html or "")
|
||||
rt = _reading_time(plaintext)
|
||||
return html, plaintext, rt
|
||||
|
||||
|
||||
async def _ensure_slug_unique(sess: AsyncSession, slug: str, exclude_post_id: int | None = None) -> str:
|
||||
"""Append -2, -3, etc. if slug already taken."""
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while True:
|
||||
q = select(Post.id).where(Post.slug == slug, Post.deleted_at.is_(None))
|
||||
if exclude_post_id:
|
||||
q = q.where(Post.id != exclude_post_id)
|
||||
existing = await sess.scalar(q)
|
||||
if existing is None:
|
||||
return slug
|
||||
counter += 1
|
||||
slug = f"{base_slug}-{counter}"
|
||||
|
||||
|
||||
async def _upsert_tags_by_name(sess: AsyncSession, tag_names: list[str]) -> list[Tag]:
|
||||
"""Find or create tags by name. Returns Tag objects in order."""
|
||||
tags: list[Tag] = []
|
||||
for name in tag_names:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
continue
|
||||
tag = (await sess.execute(
|
||||
select(Tag).where(Tag.name == name, Tag.deleted_at.is_(None))
|
||||
)).scalar_one_or_none()
|
||||
if tag is None:
|
||||
tag = Tag(
|
||||
name=name,
|
||||
slug=_slugify(name),
|
||||
visibility="public",
|
||||
)
|
||||
sess.add(tag)
|
||||
await sess.flush()
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
async def _rebuild_post_tags(sess: AsyncSession, post_id: int, tags: list[Tag]) -> None:
|
||||
"""Replace all post_tags for a post."""
|
||||
await sess.execute(delete(PostTag).where(PostTag.post_id == post_id))
|
||||
seen: set[int] = set()
|
||||
for idx, tag in enumerate(tags):
|
||||
if tag.id not in seen:
|
||||
seen.add(tag.id)
|
||||
sess.add(PostTag(post_id=post_id, tag_id=tag.id, sort_order=idx))
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def _rebuild_post_users(sess: AsyncSession, post_id: int, user_ids: list[int]) -> None:
|
||||
"""Replace all post_users for a post."""
|
||||
await sess.execute(delete(PostUser).where(PostUser.post_id == post_id))
|
||||
seen: set[int] = set()
|
||||
for idx, uid in enumerate(user_ids):
|
||||
if uid not in seen:
|
||||
seen.add(uid)
|
||||
sess.add(PostUser(post_id=post_id, user_id=uid, sort_order=idx))
|
||||
await sess.flush()
|
||||
|
||||
|
||||
async def _fire_ap_publish(
|
||||
sess: AsyncSession,
|
||||
post: Post,
|
||||
old_status: str | None,
|
||||
tag_objs: list[Tag],
|
||||
) -> None:
|
||||
"""Fire AP federation activity on status transitions."""
|
||||
if post.is_page or not post.user_id:
|
||||
return
|
||||
|
||||
from bp.blog.ghost.ghost_sync import _build_ap_post_data
|
||||
from shared.services.federation_publish import try_publish
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
post_url = app_url("blog", f"/{post.slug}/")
|
||||
|
||||
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, tag_objs),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_post(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
lexical_json: str,
|
||||
status: str = "draft",
|
||||
user_id: int,
|
||||
feature_image: str | None = None,
|
||||
custom_excerpt: str | None = None,
|
||||
feature_image_caption: str | None = None,
|
||||
tag_names: list[str] | None = None,
|
||||
is_page: bool = False,
|
||||
sx_content: str | None = None,
|
||||
) -> Post:
|
||||
"""Create a new post or page directly in db_blog."""
|
||||
html, plaintext, reading_time = _render_and_extract(lexical_json)
|
||||
slug = await _ensure_slug_unique(sess, _slugify(title or "untitled"))
|
||||
|
||||
now = utcnow()
|
||||
post = Post(
|
||||
title=title or "Untitled",
|
||||
slug=slug,
|
||||
lexical=lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json),
|
||||
sx_content=sx_content,
|
||||
html=html,
|
||||
plaintext=plaintext,
|
||||
reading_time=reading_time,
|
||||
status=status,
|
||||
is_page=is_page,
|
||||
feature_image=feature_image,
|
||||
feature_image_caption=_sanitize_html(feature_image_caption),
|
||||
custom_excerpt=custom_excerpt,
|
||||
user_id=user_id,
|
||||
visibility="public",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
published_at=now if status == "published" else None,
|
||||
)
|
||||
sess.add(post)
|
||||
await sess.flush()
|
||||
|
||||
# Tags
|
||||
if tag_names:
|
||||
tags = await _upsert_tags_by_name(sess, tag_names)
|
||||
await _rebuild_post_tags(sess, post.id, tags)
|
||||
if tags:
|
||||
post.primary_tag_id = tags[0].id
|
||||
|
||||
# Post users (author)
|
||||
await _rebuild_post_users(sess, post.id, [user_id])
|
||||
|
||||
# PageConfig for pages
|
||||
if is_page:
|
||||
from shared.models.page_config import PageConfig
|
||||
existing = (await sess.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
sess.add(PageConfig(
|
||||
container_type="page",
|
||||
container_id=post.id,
|
||||
features={},
|
||||
))
|
||||
|
||||
await sess.flush()
|
||||
|
||||
# AP federation
|
||||
if status == "published":
|
||||
tag_objs = (await _upsert_tags_by_name(sess, tag_names)) if tag_names else []
|
||||
await _fire_ap_publish(sess, post, None, tag_objs)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
async def create_page(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
lexical_json: str,
|
||||
status: str = "draft",
|
||||
user_id: int,
|
||||
feature_image: str | None = None,
|
||||
custom_excerpt: str | None = None,
|
||||
feature_image_caption: str | None = None,
|
||||
tag_names: list[str] | None = None,
|
||||
sx_content: str | None = None,
|
||||
) -> Post:
|
||||
"""Create a new page. Convenience wrapper around create_post."""
|
||||
return await create_post(
|
||||
sess,
|
||||
title=title,
|
||||
lexical_json=lexical_json,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
feature_image=feature_image,
|
||||
custom_excerpt=custom_excerpt,
|
||||
feature_image_caption=feature_image_caption,
|
||||
tag_names=tag_names,
|
||||
is_page=True,
|
||||
sx_content=sx_content,
|
||||
)
|
||||
|
||||
|
||||
async def update_post(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
post_id: int,
|
||||
lexical_json: str,
|
||||
title: str | None = None,
|
||||
expected_updated_at: datetime | str,
|
||||
feature_image: str | None = ..., # type: ignore[assignment]
|
||||
custom_excerpt: str | None = ..., # type: ignore[assignment]
|
||||
feature_image_caption: str | None = ..., # type: ignore[assignment]
|
||||
status: str | None = None,
|
||||
sx_content: str | None = ..., # type: ignore[assignment]
|
||||
) -> Post:
|
||||
"""Update post content. Optimistic lock via expected_updated_at.
|
||||
|
||||
Fields set to ... (sentinel) are left unchanged. None clears the field.
|
||||
Raises ValueError on optimistic lock conflict (409).
|
||||
"""
|
||||
_SENTINEL = ...
|
||||
|
||||
post = await sess.get(Post, post_id)
|
||||
if post is None:
|
||||
raise ValueError(f"Post {post_id} not found")
|
||||
|
||||
# Optimistic lock
|
||||
if isinstance(expected_updated_at, str):
|
||||
expected_updated_at = datetime.fromisoformat(
|
||||
expected_updated_at.replace("Z", "+00:00")
|
||||
)
|
||||
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
|
||||
raise OptimisticLockError(
|
||||
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
|
||||
)
|
||||
|
||||
old_status = post.status
|
||||
|
||||
# Render content
|
||||
html, plaintext, reading_time = _render_and_extract(lexical_json)
|
||||
post.lexical = lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json)
|
||||
post.html = html
|
||||
post.plaintext = plaintext
|
||||
post.reading_time = reading_time
|
||||
|
||||
if title is not None:
|
||||
post.title = title
|
||||
|
||||
if sx_content is not _SENTINEL:
|
||||
post.sx_content = sx_content
|
||||
|
||||
if feature_image is not _SENTINEL:
|
||||
post.feature_image = feature_image
|
||||
if custom_excerpt is not _SENTINEL:
|
||||
post.custom_excerpt = custom_excerpt
|
||||
if feature_image_caption is not _SENTINEL:
|
||||
post.feature_image_caption = _sanitize_html(feature_image_caption)
|
||||
|
||||
if status is not None:
|
||||
post.status = status
|
||||
if status == "published" and not post.published_at:
|
||||
post.published_at = utcnow()
|
||||
|
||||
post.updated_at = utcnow()
|
||||
await sess.flush()
|
||||
|
||||
# AP federation on status change
|
||||
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||
await _fire_ap_publish(sess, post, old_status, tags)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
_SETTINGS_FIELDS = (
|
||||
"slug", "published_at", "featured", "visibility", "email_only",
|
||||
"custom_template", "meta_title", "meta_description", "canonical_url",
|
||||
"og_image", "og_title", "og_description",
|
||||
"twitter_image", "twitter_title", "twitter_description",
|
||||
"feature_image_alt",
|
||||
)
|
||||
|
||||
|
||||
async def update_post_settings(
|
||||
sess: AsyncSession,
|
||||
*,
|
||||
post_id: int,
|
||||
expected_updated_at: datetime | str,
|
||||
tag_names: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Post:
|
||||
"""Update post settings (slug, tags, SEO, social, etc.).
|
||||
|
||||
Optimistic lock via expected_updated_at.
|
||||
"""
|
||||
post = await sess.get(Post, post_id)
|
||||
if post is None:
|
||||
raise ValueError(f"Post {post_id} not found")
|
||||
|
||||
# Optimistic lock
|
||||
if isinstance(expected_updated_at, str):
|
||||
expected_updated_at = datetime.fromisoformat(
|
||||
expected_updated_at.replace("Z", "+00:00")
|
||||
)
|
||||
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
|
||||
raise OptimisticLockError(
|
||||
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
|
||||
)
|
||||
|
||||
old_status = post.status
|
||||
|
||||
for field in _SETTINGS_FIELDS:
|
||||
val = kwargs.get(field)
|
||||
if val is not None:
|
||||
if field == "slug":
|
||||
val = await _ensure_slug_unique(sess, val, exclude_post_id=post.id)
|
||||
if field == "featured":
|
||||
val = bool(val)
|
||||
if field == "email_only":
|
||||
val = bool(val)
|
||||
if field == "published_at":
|
||||
if isinstance(val, str):
|
||||
val = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
setattr(post, field, val)
|
||||
|
||||
# Tags
|
||||
if tag_names is not None:
|
||||
tags = await _upsert_tags_by_name(sess, tag_names)
|
||||
await _rebuild_post_tags(sess, post.id, tags)
|
||||
post.primary_tag_id = tags[0].id if tags else None
|
||||
|
||||
post.updated_at = utcnow()
|
||||
await sess.flush()
|
||||
|
||||
# AP federation if visibility/status changed
|
||||
tags_obj = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||
await _fire_ap_publish(sess, post, old_status, tags_obj)
|
||||
|
||||
return post
|
||||
|
||||
|
||||
async def delete_post(sess: AsyncSession, post_id: int) -> None:
|
||||
"""Soft-delete a post via deleted_at."""
|
||||
post = await sess.get(Post, post_id)
|
||||
if post is None:
|
||||
return
|
||||
|
||||
old_status = post.status
|
||||
post.deleted_at = utcnow()
|
||||
post.status = "deleted"
|
||||
await sess.flush()
|
||||
|
||||
# Fire AP Delete if was published
|
||||
if old_status == "published" and post.user_id:
|
||||
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||
await _fire_ap_publish(sess, post, old_status, tags)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OptimisticLockError(Exception):
|
||||
"""Raised when optimistic lock check fails (stale updated_at)."""
|
||||
pass
|
||||
@@ -1,178 +0,0 @@
|
||||
;; Blog admin panel components
|
||||
|
||||
(defcomp ~blog-cache-panel (&key clear-url csrf)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
(div :class "flex flex-col md:flex-row gap-3 items-start"
|
||||
(form :hx-post clear-url :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
|
||||
(div :id "cache-status" :class "py-2"))))
|
||||
|
||||
(defcomp ~blog-snippets-panel (&key list-html)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-between items-center"
|
||||
(h1 :class "text-3xl font-bold" "Snippets"))
|
||||
(div :id "snippets-list" (raw! list-html))))
|
||||
|
||||
(defcomp ~blog-snippets-empty ()
|
||||
(div :class "bg-white rounded-lg shadow"
|
||||
(div :class "p-8 text-center text-stone-400"
|
||||
(i :class "fa fa-puzzle-piece text-4xl mb-2")
|
||||
(p "No snippets yet. Create one from the blog editor."))))
|
||||
|
||||
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options-html cls)
|
||||
(select :name "visibility" :hx-patch patch-url :hx-target "#snippets-list" :hx-swap "innerHTML"
|
||||
:hx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
||||
(raw! options-html)))
|
||||
|
||||
(defcomp ~blog-snippet-option (&key value selected label)
|
||||
(option :value value :selected selected label))
|
||||
|
||||
(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers)
|
||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
||||
:data-confirm-event "confirmed"
|
||||
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"
|
||||
:hx-headers hx-headers
|
||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
|
||||
(i :class "fa fa-trash") " Delete"))
|
||||
|
||||
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra-html)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-500" owner))
|
||||
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
|
||||
(raw! extra-html)))
|
||||
|
||||
(defcomp ~blog-snippets-list (&key rows-html)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
|
||||
|
||||
(defcomp ~blog-menu-items-panel (&key new-url list-html)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-end items-center"
|
||||
(button :type "button" :hx-get new-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
(i :class "fa fa-plus") " Add Menu Item"))
|
||||
(div :id "menu-item-form" :class "mb-6")
|
||||
(div :id "menu-items-list" (raw! list-html))))
|
||||
|
||||
(defcomp ~blog-menu-items-empty ()
|
||||
(div :class "bg-white rounded-lg shadow"
|
||||
(div :class "p-8 text-center text-stone-400"
|
||||
(i :class "fa fa-inbox text-4xl mb-2")
|
||||
(p "No menu items yet. Add one to get started!"))))
|
||||
|
||||
(defcomp ~blog-menu-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-menu-item-row (&key img-html label slug sort-order edit-url delete-url confirm-text hx-headers)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
||||
(raw! img-html)
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" label)
|
||||
(div :class "text-xs text-stone-500 truncate" slug))
|
||||
(div :class "text-sm text-stone-500" (str "Order: " sort-order))
|
||||
(div :class "flex gap-2 flex-shrink-0"
|
||||
(button :type "button" :hx-get edit-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
|
||||
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
||||
(i :class "fa fa-edit") " Edit")
|
||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
||||
:data-confirm-event "confirmed"
|
||||
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"
|
||||
:hx-headers hx-headers
|
||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
|
||||
(i :class "fa fa-trash") " Delete"))))
|
||||
|
||||
(defcomp ~blog-menu-items-list (&key rows-html)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
|
||||
|
||||
;; Tag groups admin
|
||||
|
||||
(defcomp ~blog-tag-groups-create-form (&key create-url csrf)
|
||||
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(h3 :class "text-sm font-semibold text-stone-700" "New Group")
|
||||
(div :class "flex flex-col sm:flex-row gap-3"
|
||||
(input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")
|
||||
(input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")
|
||||
(input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))
|
||||
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create")))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-color (&key style initial)
|
||||
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||
:style style initial))
|
||||
|
||||
(defcomp ~blog-tag-group-li (&key icon-html edit-href name slug sort-order)
|
||||
(li :class "border rounded p-3 bg-white flex items-center gap-3"
|
||||
(raw! icon-html)
|
||||
(div :class "flex-1"
|
||||
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name)
|
||||
(span :class "text-xs text-stone-500 ml-2" slug))
|
||||
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
|
||||
|
||||
(defcomp ~blog-tag-groups-list (&key items-html)
|
||||
(ul :class "space-y-2" (raw! items-html)))
|
||||
|
||||
(defcomp ~blog-tag-groups-empty ()
|
||||
(p :class "text-stone-500 text-sm" "No tag groups yet."))
|
||||
|
||||
(defcomp ~blog-unassigned-tag (&key name)
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
|
||||
|
||||
(defcomp ~blog-unassigned-tags (&key heading spans-html)
|
||||
(div :class "border-t pt-4"
|
||||
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
|
||||
(div :class "flex flex-wrap gap-2" (raw! spans-html))))
|
||||
|
||||
(defcomp ~blog-tag-groups-main (&key form-html groups-html unassigned-html)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
|
||||
(raw! form-html) (raw! groups-html) (raw! unassigned-html)))
|
||||
|
||||
;; Tag group edit
|
||||
|
||||
(defcomp ~blog-tag-checkbox (&key tag-id checked img-html name)
|
||||
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
|
||||
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
|
||||
(raw! img-html) (span name)))
|
||||
|
||||
(defcomp ~blog-tag-checkbox-image (&key src)
|
||||
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags-html)
|
||||
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div :class "space-y-3"
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")
|
||||
(input :type "text" :name "name" :value name :required "" :class "w-full border rounded px-3 py-2 text-sm"))
|
||||
(div :class "flex gap-3"
|
||||
(div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")
|
||||
(input :type "text" :name "colour" :value colour :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))
|
||||
(div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")
|
||||
(input :type "number" :name "sort_order" :value sort-order :class "w-full border rounded px-3 py-2 text-sm")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")
|
||||
(input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")
|
||||
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"
|
||||
(raw! tags-html)))
|
||||
(div :class "flex gap-3"
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
|
||||
|
||||
(defcomp ~blog-tag-group-delete-form (&key delete-url csrf)
|
||||
(form :method "post" :action delete-url :class "border-t pt-4"
|
||||
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-main (&key edit-form-html delete-form-html)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
(raw! edit-form-html) (raw! delete-form-html)))
|
||||
@@ -1,89 +0,0 @@
|
||||
;; Blog card components
|
||||
|
||||
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
||||
(button :hx-post like-url :hx-swap "outerHTML"
|
||||
:hx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
|
||||
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
||||
(<> (div :class "flex justify-center gap-2 mt-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when 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")))
|
||||
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
|
||||
|
||||
(defcomp ~blog-published-status (&key timestamp)
|
||||
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
|
||||
|
||||
(defcomp ~blog-card (&key like-html href hx-select title status-html feature-image excerpt widget-html at-bar-html)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(raw! like-html)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(raw! status-html))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
(when widget-html (raw! widget-html))
|
||||
(raw! at-bar-html)))
|
||||
|
||||
(defcomp ~blog-card-tile (&key href hx-select feature-image title status-html excerpt at-bar-html)
|
||||
(article :class "relative"
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
|
||||
(div :class "p-3 text-center"
|
||||
(h2 :class "text-lg font-bold text-stone-900" title)
|
||||
(raw! status-html)
|
||||
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
|
||||
(raw! at-bar-html)))
|
||||
|
||||
(defcomp ~blog-tag-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-tag-icon-initial (&key initial)
|
||||
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial))
|
||||
|
||||
(defcomp ~blog-tag-li (&key icon-html name)
|
||||
(li (a :class "flex items-center gap-1" (raw! icon-html)
|
||||
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
|
||||
|
||||
(defcomp ~blog-tag-bar (&key items-html)
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
|
||||
|
||||
(defcomp ~blog-author-with-image (&key image name)
|
||||
(li :class "flex items-center gap-1"
|
||||
(img :src image :alt name :class "h-5 w-5 rounded-full object-cover")
|
||||
(span :class "text-stone-700" name)))
|
||||
|
||||
(defcomp ~blog-author-text (&key name)
|
||||
(li :class "text-stone-700" name))
|
||||
|
||||
(defcomp ~blog-author-bar (&key items-html)
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
|
||||
|
||||
(defcomp ~blog-at-bar (&key tag-items-html author-items-html)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(raw! tag-items-html) (div) (raw! author-items-html)))
|
||||
|
||||
(defcomp ~blog-page-badges (&key has-calendar has-market)
|
||||
(div :class "flex justify-center gap-2 mt-2"
|
||||
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||
(i :class "fa fa-calendar mr-1") "Calendar"))
|
||||
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
(i :class "fa fa-shopping-bag mr-1") "Market"))))
|
||||
|
||||
(defcomp ~blog-page-card (&key href hx-select title badges-html pub-html feature-image excerpt)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(raw! badges-html) (raw! pub-html))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))
|
||||
@@ -1,57 +0,0 @@
|
||||
;; Blog post detail components
|
||||
|
||||
(defcomp ~blog-detail-edit-link (&key href hx-select)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
|
||||
(i :class "fa fa-pencil mr-1") " Edit"))
|
||||
|
||||
(defcomp ~blog-detail-draft (&key publish-requested edit-html)
|
||||
(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")
|
||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||
(raw! edit-html)))
|
||||
|
||||
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||
(button :hx-post like-url :hx-swap "outerHTML"
|
||||
:hx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
|
||||
(defcomp ~blog-detail-excerpt (&key excerpt)
|
||||
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
||||
|
||||
(defcomp ~blog-detail-chrome (&key like-html excerpt-html at-bar-html)
|
||||
(<> (raw! like-html) (raw! excerpt-html) (div :class "hidden md:block" (raw! at-bar-html))))
|
||||
|
||||
(defcomp ~blog-detail-main (&key draft-html chrome-html feature-image html-content)
|
||||
(<> (article :class "relative"
|
||||
(raw! draft-html) (raw! chrome-html)
|
||||
(when feature-image (div :class "mb-3 flex justify-center"
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
|
||||
(when html-content (div :class "blog-content p-2" (raw! html-content))))
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
||||
(<>
|
||||
(meta :name "robots" :content robots)
|
||||
(title page-title)
|
||||
(meta :name "description" :content desc)
|
||||
(when canonical (link :rel "canonical" :href canonical))
|
||||
(meta :property "og:type" :content og-type)
|
||||
(meta :property "og:title" :content og-title)
|
||||
(meta :property "og:description" :content desc)
|
||||
(when canonical (meta :property "og:url" :content canonical))
|
||||
(when image (meta :property "og:image" :content image))
|
||||
(meta :name "twitter:card" :content twitter-card)
|
||||
(meta :name "twitter:title" :content twitter-title)
|
||||
(meta :name "twitter:description" :content desc)
|
||||
(when image (meta :name "twitter:image" :content image))))
|
||||
|
||||
(defcomp ~blog-home-main (&key html-content)
|
||||
(article :class "relative" (div :class "blog-content p-2" (raw! html-content))))
|
||||
|
||||
(defcomp ~blog-admin-empty ()
|
||||
(div :class "pb-8"))
|
||||
|
||||
(defcomp ~blog-settings-empty ()
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6"))
|
||||
@@ -1,54 +0,0 @@
|
||||
;; Blog editor components
|
||||
|
||||
(defcomp ~blog-editor-error (&key 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:") " " error))
|
||||
|
||||
(defcomp ~blog-editor-form (&key csrf title-placeholder create-label)
|
||||
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty"
|
||||
(button :type "button" :id "feature-image-add-btn"
|
||||
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
"+ Add feature image"))
|
||||
(div :id "feature-image-filled" :class "relative hidden"
|
||||
(img :id "feature-image-preview" :src "" :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(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"))
|
||||
(input :type "text" :id "feature-image-caption" :value ""
|
||||
:placeholder "Add a caption..."
|
||||
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
|
||||
(div :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") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value "" :placeholder title-placeholder
|
||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||
(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")
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent")
|
||||
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :name "status"
|
||||
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||
(option :value "draft" :selected t "Draft")
|
||||
(option :value "published" "Published"))
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key css-href)
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
"#lexical-editor { display: flow-root; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
||||
|
||||
(defcomp ~blog-editor-scripts (&key js-src init-js)
|
||||
(<> (script :src js-src) (script (raw! init-js))))
|
||||
@@ -1,72 +0,0 @@
|
||||
;; Blog index components
|
||||
|
||||
(defcomp ~blog-end-of-results ()
|
||||
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
|
||||
|
||||
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
|
||||
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
||||
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"
|
||||
:hx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-8"
|
||||
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-8 text-stone-400"
|
||||
(i :class "fa fa-exclamation-triangle text-2xl")
|
||||
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
||||
|
||||
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
|
||||
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
|
||||
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry"
|
||||
:hx-swap "outerHTML" :_ hyperscript
|
||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
||||
(div :class "js-loading hidden flex justify-center py-2"
|
||||
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
|
||||
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
|
||||
|
||||
(defcomp ~blog-page-sentinel (&key id next-url)
|
||||
(div :id id :class "h-4 opacity-0 pointer-events-none"
|
||||
:hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"))
|
||||
|
||||
(defcomp ~blog-no-pages ()
|
||||
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
|
||||
|
||||
(defcomp ~blog-list-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
|
||||
|
||||
(defcomp ~blog-tile-svg ()
|
||||
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
|
||||
:stroke "currentColor" :stroke-width "2"
|
||||
(path :stroke-linecap "round" :stroke-linejoin "round"
|
||||
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
|
||||
|
||||
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg-html tile-svg-html)
|
||||
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
|
||||
(a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
|
||||
:_ "on click js localStorage.removeItem('blog_view') end" (raw! list-svg-html))
|
||||
(a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
|
||||
:_ "on click js localStorage.setItem('blog_view','tile') end" (raw! tile-svg-html))))
|
||||
|
||||
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
||||
(div :class "flex justify-center gap-1 px-3 pt-3"
|
||||
(a :href posts-href :hx-get posts-href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts")
|
||||
(a :href pages-href :hx-get pages-href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
|
||||
|
||||
(defcomp ~blog-main-panel-pages (&key tabs-html cards-html)
|
||||
(<> (raw! tabs-html) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards-html)) (div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-main-panel-posts (&key tabs-html toggle-html grid-cls cards-html)
|
||||
(<> (raw! tabs-html) (raw! toggle-html) (div :class grid-cls (raw! cards-html)) (div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-aside (&key search-html action-buttons-html tag-groups-filter-html authors-filter-html)
|
||||
(<> (raw! search-html) (raw! action-buttons-html)
|
||||
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
|
||||
(raw! tag-groups-filter-html) (raw! authors-filter-html))
|
||||
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
|
||||
@@ -1,67 +0,0 @@
|
||||
;; Blog navigation components
|
||||
|
||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
||||
(div :id wrapper-id :hx-swap-oob "outerHTML"))
|
||||
|
||||
(defcomp ~blog-nav-item-image (&key src label)
|
||||
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img-html label)
|
||||
(div (a :href href :hx-get hx-get :hx-target "#main-panel"
|
||||
:hx-swap "outerHTML" :hx-push-url "true"
|
||||
:aria-selected selected :class nav-cls
|
||||
(raw! img-html) (span label))))
|
||||
|
||||
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img-html label)
|
||||
(div (a :href href :aria-selected selected :class nav-cls
|
||||
(raw! img-html) (span label))))
|
||||
|
||||
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items-html)
|
||||
(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"
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll left"
|
||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
||||
(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"
|
||||
:style "scroll-behavior: smooth;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
||||
:aria-label "Scroll right"
|
||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
||||
|
||||
;; Nav entries
|
||||
|
||||
(defcomp ~blog-nav-entries-empty ()
|
||||
(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"))
|
||||
|
||||
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
|
||||
(a :href href :class nav-cls
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
||||
|
||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
||||
(a :href href :class nav-cls
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(div name)))
|
||||
|
||||
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items-html)
|
||||
(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"
|
||||
(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"))
|
||||
(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;" :_ scroll-hs
|
||||
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
|
||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
||||
(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"))))
|
||||
File diff suppressed because it is too large
Load Diff
285
blog/sx/admin.sx
Normal file
285
blog/sx/admin.sx
Normal file
@@ -0,0 +1,285 @@
|
||||
;; Blog admin panel components
|
||||
|
||||
(defcomp ~blog-cache-panel (&key clear-url csrf)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
(div :class "flex flex-col md:flex-row gap-3 items-start"
|
||||
(form :sx-post clear-url :sx-trigger "submit" :sx-target "#cache-status" :sx-swap "innerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
|
||||
(div :id "cache-status" :class "py-2"))))
|
||||
|
||||
(defcomp ~blog-snippets-panel (&key list)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-between items-center"
|
||||
(h1 :class "text-3xl font-bold" "Snippets"))
|
||||
(div :id "snippets-list" list)))
|
||||
|
||||
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
|
||||
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
|
||||
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
||||
options))
|
||||
|
||||
(defcomp ~blog-snippet-option (&key value selected label)
|
||||
(option :value value :selected selected label))
|
||||
|
||||
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-500" owner))
|
||||
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
|
||||
extra))
|
||||
|
||||
(defcomp ~blog-snippets-list (&key rows)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
||||
|
||||
(defcomp ~blog-menu-items-panel (&key new-url list)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-end items-center"
|
||||
(button :type "button" :sx-get new-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
(i :class "fa fa-plus") " Add Menu Item"))
|
||||
(div :id "menu-item-form" :class "mb-6")
|
||||
(div :id "menu-items-list" list)))
|
||||
|
||||
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
|
||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
||||
img
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" label)
|
||||
(div :class "text-xs text-stone-500 truncate" slug))
|
||||
(div :class "text-sm text-stone-500" (str "Order: " sort-order))
|
||||
(div :class "flex gap-2 flex-shrink-0"
|
||||
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
||||
(i :class "fa fa-edit") " Edit")
|
||||
(~delete-btn :url delete-url :trigger-target "#menu-items-list"
|
||||
:title "Delete menu item?" :text confirm-text
|
||||
:sx-headers hx-headers))))
|
||||
|
||||
(defcomp ~blog-menu-items-list (&key rows)
|
||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
||||
|
||||
;; Tag groups admin
|
||||
|
||||
(defcomp ~blog-tag-groups-create-form (&key create-url csrf)
|
||||
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(h3 :class "text-sm font-semibold text-stone-700" "New Group")
|
||||
(div :class "flex flex-col sm:flex-row gap-3"
|
||||
(input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")
|
||||
(input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")
|
||||
(input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))
|
||||
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create")))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
|
||||
(defcomp ~blog-tag-group-icon-color (&key style initial)
|
||||
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||
:style style initial))
|
||||
|
||||
(defcomp ~blog-tag-group-li (&key icon edit-href name slug sort-order)
|
||||
(li :class "border rounded p-3 bg-white flex items-center gap-3"
|
||||
icon
|
||||
(div :class "flex-1"
|
||||
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name)
|
||||
(span :class "text-xs text-stone-500 ml-2" slug))
|
||||
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
|
||||
|
||||
(defcomp ~blog-tag-groups-list (&key items)
|
||||
(ul :class "space-y-2" items))
|
||||
|
||||
(defcomp ~blog-unassigned-tag (&key name)
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
|
||||
|
||||
(defcomp ~blog-unassigned-tags (&key heading spans)
|
||||
(div :class "border-t pt-4"
|
||||
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
|
||||
(div :class "flex flex-wrap gap-2" spans)))
|
||||
|
||||
(defcomp ~blog-tag-groups-main (&key form groups unassigned)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
|
||||
form groups unassigned))
|
||||
|
||||
;; Tag group edit
|
||||
|
||||
(defcomp ~blog-tag-checkbox (&key tag-id checked img name)
|
||||
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
|
||||
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
|
||||
img (span name)))
|
||||
|
||||
(defcomp ~blog-tag-checkbox-image (&key src)
|
||||
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags)
|
||||
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div :class "space-y-3"
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")
|
||||
(input :type "text" :name "name" :value name :required "" :class "w-full border rounded px-3 py-2 text-sm"))
|
||||
(div :class "flex gap-3"
|
||||
(div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")
|
||||
(input :type "text" :name "colour" :value colour :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))
|
||||
(div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")
|
||||
(input :type "number" :name "sort_order" :value sort-order :class "w-full border rounded px-3 py-2 text-sm")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")
|
||||
(input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")
|
||||
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"
|
||||
tags))
|
||||
(div :class "flex gap-3"
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
|
||||
|
||||
(defcomp ~blog-tag-group-delete-form (&key delete-url csrf)
|
||||
(form :method "post" :action delete-url :class "border-t pt-4"
|
||||
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||
edit-form delete-form))
|
||||
|
||||
;; Preview panel components
|
||||
|
||||
(defcomp ~blog-preview-panel (&key sections)
|
||||
(div :class "max-w-4xl mx-auto px-4 py-6 space-y-4"
|
||||
(style "
|
||||
.sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.sx-list, .json-obj, .json-arr { display: block; }
|
||||
.sx-paren { color: #64748b; }
|
||||
.sx-sym { color: #0369a1; }
|
||||
.sx-kw { color: #7c3aed; }
|
||||
.sx-str { color: #15803d; }
|
||||
.sx-num { color: #c2410c; }
|
||||
.sx-bool { color: #b91c1c; font-weight: 600; }
|
||||
.json-brace, .json-bracket { color: #64748b; }
|
||||
.json-key { color: #7c3aed; }
|
||||
.json-str { color: #15803d; }
|
||||
.json-num { color: #c2410c; }
|
||||
.json-lit { color: #b91c1c; font-weight: 600; }
|
||||
.json-field { display: block; }
|
||||
")
|
||||
sections))
|
||||
|
||||
(defcomp ~blog-preview-section (&key title content)
|
||||
(details :class "border rounded bg-white"
|
||||
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
|
||||
(div :class "p-4 overflow-x-auto text-xs" content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven content defcomps (called from defpages with service data)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Snippets — receives serialized snippet dicts from service
|
||||
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
|
||||
(~blog-snippets-panel
|
||||
:list (if (empty? (or snippets (list)))
|
||||
(~empty-state :icon "fa fa-puzzle-piece"
|
||||
:message "No snippets yet. Create one from the blog editor.")
|
||||
(~blog-snippets-list
|
||||
:rows (map (lambda (s)
|
||||
(let* ((badge-colours (dict
|
||||
"private" "bg-stone-200 text-stone-700"
|
||||
"shared" "bg-blue-100 text-blue-700"
|
||||
"admin" "bg-amber-100 text-amber-700"))
|
||||
(vis (or (get s "visibility") "private"))
|
||||
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
|
||||
(name (get s "name"))
|
||||
(owner (get s "owner"))
|
||||
(can-delete (get s "can_delete")))
|
||||
(~blog-snippet-row
|
||||
:name name :owner owner :badge-cls badge-cls :visibility vis
|
||||
:extra (<>
|
||||
(when is-admin
|
||||
(~blog-snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:options (<>
|
||||
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
|
||||
(when can-delete
|
||||
(~delete-btn
|
||||
:url (get s "delete_url")
|
||||
:trigger-target "#snippets-list"
|
||||
:title "Delete snippet?"
|
||||
:text (str "Delete \u201c" name "\u201d?")
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
||||
(or snippets (list)))))))
|
||||
|
||||
;; Menu Items — receives serialized menu item dicts from service
|
||||
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
|
||||
(~blog-menu-items-panel
|
||||
:new-url new-url
|
||||
:list (if (empty? (or menu-items (list)))
|
||||
(~empty-state :icon "fa fa-inbox"
|
||||
:message "No menu items yet. Add one to get started!")
|
||||
(~blog-menu-items-list
|
||||
:rows (map (lambda (mi)
|
||||
(~blog-menu-item-row
|
||||
:img (~img-or-placeholder
|
||||
:src (get mi "feature_image") :alt (get mi "label")
|
||||
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
:label (get mi "label")
|
||||
:slug (get mi "url")
|
||||
:sort-order (str (or (get mi "sort_order") 0))
|
||||
:edit-url (get mi "edit_url")
|
||||
:delete-url (get mi "delete_url")
|
||||
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
|
||||
(or menu-items (list)))))))
|
||||
|
||||
;; Tag Groups — receives serialized tag group data from service
|
||||
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
|
||||
(~blog-tag-groups-main
|
||||
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
:groups (if (empty? (or groups (list)))
|
||||
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
|
||||
(~blog-tag-groups-list
|
||||
:items (map (lambda (g)
|
||||
(let* ((fi (get g "feature_image"))
|
||||
(colour (get g "colour"))
|
||||
(name (get g "name"))
|
||||
(initial (slice (or name "?") 0 1))
|
||||
(icon (if fi
|
||||
(~blog-tag-group-icon-image :src fi :name name)
|
||||
(~blog-tag-group-icon-color
|
||||
:style (if colour (str "background:" colour) "background:#e7e5e4")
|
||||
:initial initial))))
|
||||
(~blog-tag-group-li
|
||||
:icon icon
|
||||
:edit-href (get g "edit_href")
|
||||
:name name
|
||||
:slug (or (get g "slug") "")
|
||||
:sort-order (or (get g "sort_order") 0))))
|
||||
(or groups (list)))))
|
||||
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
||||
(~blog-unassigned-tags
|
||||
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
|
||||
:spans (map (lambda (t)
|
||||
(~blog-unassigned-tag :name (get t "name")))
|
||||
(or unassigned-tags (list)))))))
|
||||
|
||||
;; Tag Group Edit — receives serialized tag group + tags from service
|
||||
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
|
||||
(~blog-tag-group-edit-main
|
||||
:edit-form (~blog-tag-group-edit-form
|
||||
:save-url save-url :csrf csrf
|
||||
:name (get group "name")
|
||||
:colour (get group "colour")
|
||||
:sort-order (get group "sort_order")
|
||||
:feature-image (get group "feature_image")
|
||||
:tags (map (lambda (t)
|
||||
(~blog-tag-checkbox
|
||||
:tag-id (get t "id")
|
||||
:checked (get t "checked")
|
||||
:img (when (get t "feature_image")
|
||||
(~blog-tag-checkbox-image :src (get t "feature_image")))
|
||||
:name (get t "name")))
|
||||
(or all-tags (list))))
|
||||
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
||||
127
blog/sx/cards.sx
Normal file
127
blog/sx/cards.sx
Normal file
@@ -0,0 +1,127 @@
|
||||
;; Blog card components — pure data, no HTML injection
|
||||
|
||||
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
|
||||
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
||||
(<> (div :class "flex justify-center gap-2 mt-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when 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")))
|
||||
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
|
||||
|
||||
(defcomp ~blog-published-status (&key timestamp)
|
||||
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
|
||||
|
||||
;; Tag components — accept data, not HTML
|
||||
(defcomp ~blog-tag-icon (&key src name initial)
|
||||
(if src
|
||||
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")
|
||||
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)))
|
||||
|
||||
(defcomp ~blog-tag-item (&key src name initial)
|
||||
(li (a :class "flex items-center gap-1"
|
||||
(~blog-tag-icon :src src :name name :initial initial)
|
||||
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
|
||||
|
||||
;; At-bar — tags + authors row for detail pages
|
||||
(defcomp ~blog-at-bar (&key tags authors)
|
||||
(when (or tags authors)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))
|
||||
|
||||
;; Author components
|
||||
(defcomp ~blog-author-item (&key image name)
|
||||
(li :class "flex items-center gap-1"
|
||||
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
(span :class "text-stone-700" name)))
|
||||
|
||||
;; Card — accepts pure data
|
||||
(defcomp ~blog-card (&key slug href hx-select title
|
||||
feature-image excerpt
|
||||
status is-draft publish-requested status-timestamp
|
||||
liked like-url csrf-token
|
||||
has-like
|
||||
tags authors widget)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(when has-like
|
||||
(~blog-like-button
|
||||
:like-url like-url
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(if is-draft
|
||||
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~blog-published-status :timestamp status-timestamp))))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
widget
|
||||
(when (or tags authors)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
|
||||
(defcomp ~blog-card-tile (&key href hx-select feature-image title
|
||||
is-draft publish-requested status-timestamp
|
||||
excerpt tags authors)
|
||||
(article :class "relative"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
|
||||
(div :class "p-3 text-center"
|
||||
(h2 :class "text-lg font-bold text-stone-900" title)
|
||||
(if is-draft
|
||||
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))
|
||||
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
|
||||
(when (or tags authors)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
|
||||
(defcomp ~blog-page-badges (&key has-calendar has-market)
|
||||
(div :class "flex justify-center gap-2 mt-2"
|
||||
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||
(i :class "fa fa-calendar mr-1") "Calendar"))
|
||||
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
(i :class "fa fa-shopping-bag mr-1") "Market"))))
|
||||
|
||||
(defcomp ~blog-page-card (&key href hx-select title has-calendar has-market pub-timestamp feature-image excerpt)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(~blog-page-badges :has-calendar has-calendar :has-market has-market)
|
||||
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp)))
|
||||
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
|
||||
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))
|
||||
90
blog/sx/detail.sx
Normal file
90
blog/sx/detail.sx
Normal file
@@ -0,0 +1,90 @@
|
||||
;; Blog post detail components
|
||||
|
||||
(defcomp ~blog-detail-edit-link (&key href hx-select)
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
|
||||
(i :class "fa fa-pencil mr-1") " Edit"))
|
||||
|
||||
(defcomp ~blog-detail-draft (&key publish-requested edit)
|
||||
(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")
|
||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||
edit))
|
||||
|
||||
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
|
||||
(defcomp ~blog-detail-excerpt (&key excerpt)
|
||||
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
||||
|
||||
(defcomp ~blog-detail-chrome (&key like excerpt at-bar)
|
||||
(<> like
|
||||
excerpt
|
||||
(div :class "hidden md:block" at-bar)))
|
||||
|
||||
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content)
|
||||
(<> (article :class "relative"
|
||||
draft
|
||||
chrome
|
||||
(when feature-image (div :class "mb-3 flex justify-center"
|
||||
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
|
||||
(if sx-content
|
||||
(div :class "blog-content p-2" sx-content)
|
||||
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||
(div :class "pb-8")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven composition — replaces _post_main_panel_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-post-detail-content (&key slug is-draft publish-requested can-edit edit-href
|
||||
is-page has-user liked like-url csrf
|
||||
custom-excerpt tags authors
|
||||
feature-image html-content sx-content)
|
||||
(let* ((hx-select "#main-panel")
|
||||
(draft-sx (when is-draft
|
||||
(~blog-detail-draft
|
||||
:publish-requested publish-requested
|
||||
:edit (when can-edit
|
||||
(~blog-detail-edit-link :href edit-href :hx-select hx-select)))))
|
||||
(chrome-sx (when (not is-page)
|
||||
(~blog-detail-chrome
|
||||
:like (when has-user
|
||||
(~blog-detail-like
|
||||
:like-url like-url
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:heart (if liked "\u2764\ufe0f" "\U0001f90d")))
|
||||
:excerpt (when (not (= custom-excerpt ""))
|
||||
(~blog-detail-excerpt :excerpt custom-excerpt))
|
||||
:at-bar (~blog-at-bar :tags tags :authors authors)))))
|
||||
(~blog-detail-main
|
||||
:draft draft-sx
|
||||
:chrome chrome-sx
|
||||
:feature-image feature-image
|
||||
:html-content html-content
|
||||
:sx-content sx-content)))
|
||||
|
||||
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
||||
(<>
|
||||
(meta :name "robots" :content robots)
|
||||
(title page-title)
|
||||
(meta :name "description" :content desc)
|
||||
(when canonical (link :rel "canonical" :href canonical))
|
||||
(meta :property "og:type" :content og-type)
|
||||
(meta :property "og:title" :content og-title)
|
||||
(meta :property "og:description" :content desc)
|
||||
(when canonical (meta :property "og:url" :content canonical))
|
||||
(when image (meta :property "og:image" :content image))
|
||||
(meta :name "twitter:card" :content twitter-card)
|
||||
(meta :name "twitter:title" :content twitter-title)
|
||||
(meta :name "twitter:description" :content desc)
|
||||
(when image (meta :name "twitter:image" :content image))))
|
||||
|
||||
(defcomp ~blog-home-main (&key html-content sx-content)
|
||||
(article :class "relative"
|
||||
(if sx-content
|
||||
(div :class "blog-content p-2" sx-content)
|
||||
(div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||
305
blog/sx/editor.sx
Normal file
305
blog/sx/editor.sx
Normal file
@@ -0,0 +1,305 @@
|
||||
;; Blog editor components
|
||||
|
||||
(defcomp ~blog-editor-error (&key 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:") " " error))
|
||||
|
||||
(defcomp ~blog-editor-form (&key csrf title-placeholder create-label)
|
||||
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value "")
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty"
|
||||
(button :type "button" :id "feature-image-add-btn"
|
||||
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
"+ Add feature image"))
|
||||
(div :id "feature-image-filled" :class "relative hidden"
|
||||
(img :id "feature-image-preview" :src "" :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(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"))
|
||||
(input :type "text" :id "feature-image-caption" :value ""
|
||||
:placeholder "Add a caption..."
|
||||
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
|
||||
(div :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") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value "" :placeholder title-placeholder
|
||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||
(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")
|
||||
;; Editor tabs: SX (primary) and Koenig (legacy)
|
||||
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||
(button :type "button" :id "editor-tab-sx"
|
||||
:class "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent"
|
||||
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"SX Editor")
|
||||
(button :type "button" :id "editor-tab-koenig"
|
||||
:class "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"
|
||||
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"Koenig (Legacy)"))
|
||||
(div :id "sx-editor" :class "relative w-full bg-transparent")
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent" :style "display:none")
|
||||
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :name "status"
|
||||
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||
(option :value "draft" :selected true "Draft")
|
||||
(option :value "published" "Published"))
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
|
||||
|
||||
;; Edit form — pre-populated version for /<slug>/admin/edit/
|
||||
(defcomp ~blog-editor-edit-form (&key csrf updated-at title-val excerpt-val
|
||||
feature-image feature-image-caption
|
||||
sx-content-val lexical-json
|
||||
has-sx title-placeholder
|
||||
status already-emailed
|
||||
newsletter-options footer-extra)
|
||||
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
|
||||
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
|
||||
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
|
||||
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "updated_at" :value updated-at)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
|
||||
(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"))
|
||||
(div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden"))
|
||||
(img :id "feature-image-preview" :src (or feature-image "") :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(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"))
|
||||
(input :type "text" :id "feature-image-caption" :value (or feature-image-caption "")
|
||||
: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 :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") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder
|
||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||
(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"
|
||||
(or excerpt-val ""))
|
||||
;; Editor tabs
|
||||
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||
(button :type "button" :id "editor-tab-sx"
|
||||
:class (if has-sx active inactive)
|
||||
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"SX Editor")
|
||||
(button :type "button" :id "editor-tab-koenig"
|
||||
:class (if has-sx inactive active)
|
||||
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"Koenig (Legacy)"))
|
||||
(div :id "sx-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "" "display:none"))
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "display:none" ""))
|
||||
;; Initial lexical JSON
|
||||
(script :id "lexical-initial-data" :type "application/json" lexical-json)
|
||||
;; Footer: status + publish mode + newsletter + save + badges
|
||||
(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 sel-cls
|
||||
(option :value "draft" :selected (= status "draft") "Draft")
|
||||
(option :value "published" :selected (= status "published") "Published"))
|
||||
(select :id "publish-mode-select" :name "publish_mode"
|
||||
:class (str sel-cls (if (= status "published") "" " hidden")
|
||||
(if already-emailed " opacity-50 pointer-events-none" ""))
|
||||
:disabled (if already-emailed true nil)
|
||||
(option :value "web" :selected true "Web only")
|
||||
(option :value "email" "Email only")
|
||||
(option :value "both" "Web + Email"))
|
||||
(select :id "newsletter-select" :name "newsletter_slug"
|
||||
:class (str sel-cls " hidden")
|
||||
:disabled (if already-emailed true nil)
|
||||
newsletter-options)
|
||||
(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")
|
||||
(when footer-extra footer-extra)))))
|
||||
|
||||
;; Publish-mode show/hide script for edit form
|
||||
(defcomp ~blog-editor-publish-js (&key already-emailed)
|
||||
(script
|
||||
"(function() {"
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
" var modeSel = document.getElementById('publish-mode-select');"
|
||||
" var nlSel = document.getElementById('newsletter-select');"
|
||||
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
|
||||
" function sync() {"
|
||||
" var isPublished = statusSel.value === 'published';"
|
||||
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
|
||||
" 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();"
|
||||
"})();"))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key css-href)
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
"#lexical-editor { display: flow-root; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
||||
|
||||
(defcomp ~blog-editor-scripts (&key js-src sx-editor-js-src init-js)
|
||||
(<> (script :src js-src)
|
||||
(when sx-editor-js-src (script :src sx-editor-js-src))
|
||||
(script init-js)))
|
||||
|
||||
;; SX editor styles — comprehensive CSS for the Koenig-style block editor
|
||||
(defcomp ~sx-editor-styles ()
|
||||
(style
|
||||
;; Editor container
|
||||
".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }"
|
||||
".sx-blocks-container { display: flex; flex-direction: column; min-height: 300px; cursor: text; padding-left: 48px; }"
|
||||
|
||||
;; Block base
|
||||
".sx-block { position: relative; margin: 1px 0; }"
|
||||
".sx-block-content { padding: 2px 0; }"
|
||||
".sx-editable { outline: none; min-height: 1.6em; }"
|
||||
".sx-editable:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
|
||||
|
||||
;; Text block styles
|
||||
".sx-heading { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 700; }"
|
||||
".sx-block[data-sx-tag='h2'] .sx-heading { font-size: 2em; line-height: 1.25; margin: 0.5em 0 0.25em; }"
|
||||
".sx-block[data-sx-tag='h3'] .sx-heading { font-size: 1.5em; line-height: 1.3; margin: 0.4em 0 0.2em; }"
|
||||
".sx-quote { border-left: 3px solid #d6d3d1; padding-left: 16px; font-style: italic; color: #57534e; }"
|
||||
|
||||
;; HR
|
||||
".sx-block-hr { padding: 12px 0; }"
|
||||
".sx-hr { border: none; border-top: 1px solid #e7e5e4; }"
|
||||
|
||||
;; Code blocks
|
||||
".sx-block-code { background: #1c1917; border-radius: 6px; padding: 16px; margin: 8px 0; }"
|
||||
".sx-code-header { margin-bottom: 8px; }"
|
||||
".sx-code-lang { background: transparent; border: none; color: #a8a29e; font-size: 12px; outline: none; width: 120px; font-family: monospace; }"
|
||||
".sx-code-textarea { width: 100%; background: transparent; border: none; color: #fafaf9; font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace; font-size: 14px; line-height: 1.5; resize: none; outline: none; min-height: 60px; tab-size: 2; }"
|
||||
|
||||
;; List blocks
|
||||
".sx-list-content { padding-left: 0; }"
|
||||
".sx-list-item { padding: 2px 0 2px 24px; position: relative; outline: none; min-height: 1.6em; }"
|
||||
".sx-list-item:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
|
||||
".sx-list-item:before { content: '\\2022'; position: absolute; left: 6px; color: #78716c; }"
|
||||
".sx-block[data-sx-tag='ol'] { counter-reset: sx-list; }"
|
||||
".sx-block[data-sx-tag='ol'] .sx-list-item { counter-increment: sx-list; }"
|
||||
".sx-block[data-sx-tag='ol'] .sx-list-item:before { content: counter(sx-list) '.'; font-size: 14px; }"
|
||||
|
||||
;; Card blocks
|
||||
".sx-block-card { margin: 12px 0; border-radius: 4px; position: relative; transition: box-shadow 0.15s; }"
|
||||
".sx-block-card:hover { box-shadow: 0 0 0 1px #d6d3d1; }"
|
||||
".sx-block-card.sx-card-editing { box-shadow: 0 0 0 2px #3b82f6; }"
|
||||
".sx-card-preview { cursor: pointer; }"
|
||||
".sx-card-preview img { max-width: 100%; }"
|
||||
".sx-card-fallback { padding: 24px; text-align: center; color: #a8a29e; font-style: italic; background: #fafaf9; border-radius: 4px; }"
|
||||
".sx-card-caption { padding: 8px 0; font-size: 14px; color: #78716c; text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-card-caption:empty:before { content: attr(data-placeholder); color: #d6d3d1; }"
|
||||
|
||||
;; Card toolbar
|
||||
".sx-card-toolbar { position: absolute; top: -36px; right: 0; z-index: 10; display: none; gap: 4px; }"
|
||||
".sx-block-card:hover .sx-card-toolbar { display: flex; }"
|
||||
".sx-card-editing .sx-card-toolbar { display: flex; }"
|
||||
".sx-card-tool-btn { width: 28px; height: 28px; border-radius: 4px; border: 1px solid #d6d3d1; background: white; color: #78716c; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: all 0.1s; }"
|
||||
".sx-card-tool-btn:hover { background: #fafaf9; color: #dc2626; border-color: #dc2626; }"
|
||||
|
||||
;; Card edit panel
|
||||
".sx-card-edit { padding: 16px; background: #fafaf9; border-radius: 4px; }"
|
||||
|
||||
;; Plus button (Koenig-style floating)
|
||||
".sx-plus-container { position: absolute; z-index: 40; display: flex; align-items: center; }"
|
||||
".sx-plus-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #d6d3d1; background: white; color: #a8a29e; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; padding: 0; }"
|
||||
".sx-plus-btn:hover { border-color: #1c1917; color: #1c1917; }"
|
||||
".sx-plus-btn-open { transform: rotate(45deg); border-color: #1c1917; color: #1c1917; }"
|
||||
".sx-plus-btn svg { width: 14px; height: 14px; }"
|
||||
|
||||
;; Plus menu
|
||||
".sx-plus-menu { position: absolute; top: 36px; left: -8px; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 8px; width: 320px; max-height: 420px; overflow-y: auto; }"
|
||||
".sx-plus-menu-section { margin-bottom: 4px; }"
|
||||
".sx-plus-menu-heading { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #a8a29e; padding: 6px 8px 2px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-plus-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 8px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-plus-menu-item:hover { background: #f5f5f4; }"
|
||||
".sx-plus-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
|
||||
".sx-plus-menu-label { font-weight: 500; white-space: nowrap; }"
|
||||
".sx-plus-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
|
||||
|
||||
;; Slash command menu
|
||||
".sx-slash-menu { position: absolute; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 4px; width: 320px; max-height: 300px; overflow-y: auto; }"
|
||||
".sx-slash-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-slash-menu-item:hover { background: #f5f5f4; }"
|
||||
".sx-slash-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
|
||||
".sx-slash-menu-label { font-weight: 500; white-space: nowrap; }"
|
||||
".sx-slash-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
|
||||
|
||||
;; Format toolbar
|
||||
".sx-format-bar { position: absolute; z-index: 100; display: flex; gap: 1px; background: #1c1917; border-radius: 6px; padding: 2px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-format-btn { border: none; background: none; color: #e7e5e4; cursor: pointer; padding: 6px 10px; border-radius: 4px; font-size: 14px; line-height: 1; }"
|
||||
".sx-format-btn:hover { background: #44403c; color: white; }"
|
||||
".sx-fmt-bold { font-weight: 700; }"
|
||||
".sx-fmt-italic { font-style: italic; }"
|
||||
|
||||
;; Card edit UI elements
|
||||
".sx-edit-controls { display: flex; flex-direction: column; gap: 8px; }"
|
||||
".sx-edit-row { display: flex; align-items: center; gap: 8px; }"
|
||||
".sx-edit-label { font-size: 12px; font-weight: 600; color: #78716c; min-width: 80px; text-transform: uppercase; letter-spacing: 0.03em; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-edit-input { flex: 1; padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-edit-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }"
|
||||
".sx-edit-select { padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; background: white; }"
|
||||
".sx-edit-btn { padding: 8px 16px; border: 1px solid #d6d3d1; border-radius: 6px; font-size: 14px; cursor: pointer; background: white; color: #1c1917; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; transition: all 0.1s; }"
|
||||
".sx-edit-btn:hover { background: #f5f5f4; border-color: #a8a29e; }"
|
||||
".sx-edit-btn-sm { padding: 4px 12px; font-size: 12px; }"
|
||||
".sx-edit-info { font-size: 12px; color: #a8a29e; }"
|
||||
".sx-edit-status { font-size: 13px; color: #78716c; margin-top: 4px; }"
|
||||
".sx-edit-url-input { font-family: monospace; }"
|
||||
".sx-edit-url-display { font-size: 12px; color: #78716c; font-family: monospace; margin: 4px 0; word-break: break-all; }"
|
||||
".sx-edit-checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 14px; color: #44403c; cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-edit-img-preview { max-width: 100%; max-height: 300px; object-fit: contain; border-radius: 4px; margin-bottom: 12px; }"
|
||||
".sx-edit-embed-preview { margin-bottom: 8px; }"
|
||||
".sx-edit-embed-preview iframe { max-width: 100%; }"
|
||||
".sx-edit-audio-player { width: 100%; margin-bottom: 12px; }"
|
||||
".sx-edit-video-player { width: 100%; max-height: 300px; margin-bottom: 12px; border-radius: 4px; }"
|
||||
".sx-edit-html-textarea { width: 100%; min-height: 120px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; padding: 12px; border: 1px solid #d6d3d1; border-radius: 4px; resize: vertical; outline: none; background: #fafaf9; line-height: 1.5; tab-size: 2; }"
|
||||
".sx-edit-html-textarea:focus { border-color: #3b82f6; }"
|
||||
".sx-edit-callout-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; }"
|
||||
".sx-edit-toggle-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; margin-top: 4px; }"
|
||||
".sx-edit-emoji-input { width: 60px; text-align: center; font-size: 20px; padding: 4px; margin-bottom: 8px; }"
|
||||
|
||||
;; Color picker
|
||||
".sx-edit-color-row { display: flex; gap: 6px; margin-bottom: 8px; }"
|
||||
".sx-edit-color-swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: all 0.1s; }"
|
||||
".sx-edit-color-swatch:hover { transform: scale(1.15); }"
|
||||
".sx-edit-color-swatch.active { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }"
|
||||
|
||||
;; Gallery grid
|
||||
".sx-edit-gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }"
|
||||
".sx-edit-gallery-thumb { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 4px; }"
|
||||
".sx-edit-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }"
|
||||
".sx-edit-gallery-remove { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.1s; }"
|
||||
".sx-edit-gallery-thumb:hover .sx-edit-gallery-remove { opacity: 1; }"
|
||||
|
||||
;; Upload area
|
||||
".sx-upload-area { padding: 40px 24px; border: 2px dashed #d6d3d1; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s; }"
|
||||
".sx-upload-area:hover, .sx-upload-dragover { border-color: #3b82f6; background: rgba(59,130,246,0.03); }"
|
||||
".sx-upload-icon { font-size: 32px; color: #a8a29e; margin-bottom: 8px; }"
|
||||
".sx-upload-msg { font-size: 14px; color: #78716c; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||
".sx-upload-progress { font-size: 13px; color: #3b82f6; margin-top: 8px; }"
|
||||
|
||||
;; Drag over editor
|
||||
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
|
||||
@@ -1,29 +1,29 @@
|
||||
;; Blog filter components
|
||||
|
||||
(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class icon-class) label))
|
||||
|
||||
(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
|
||||
(a :href href :hx-get href :hx-target "#main-panel"
|
||||
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-action-buttons-wrapper (&key inner-html)
|
||||
(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner-html)))
|
||||
(defcomp ~blog-action-buttons-wrapper (&key inner)
|
||||
(div :class "flex flex-wrap gap-2 px-4 py-3" inner))
|
||||
|
||||
(defcomp ~blog-filter-any-topic (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded border " cls)
|
||||
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" "Any Topic")))
|
||||
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
|
||||
|
||||
(defcomp ~blog-filter-group-icon-image (&key src name)
|
||||
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
|
||||
@@ -31,32 +31,32 @@
|
||||
(defcomp ~blog-filter-group-icon-color (&key style initial)
|
||||
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
|
||||
|
||||
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon-html name count)
|
||||
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon name count)
|
||||
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
|
||||
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true"
|
||||
(raw! icon-html)
|
||||
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
icon
|
||||
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name)
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-nav (&key items-html)
|
||||
(defcomp ~blog-filter-nav (&key items)
|
||||
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
|
||||
(ul :class "divide-y flex flex-col gap-3" (raw! items-html))))
|
||||
(ul :class "divide-y flex flex-col gap-3" items)))
|
||||
|
||||
(defcomp ~blog-filter-any-author (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded " cls)
|
||||
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true" "Any author")))
|
||||
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" "Any author")))
|
||||
|
||||
(defcomp ~blog-filter-author-icon (&key src name)
|
||||
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon-html name count)
|
||||
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon name count)
|
||||
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
|
||||
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
|
||||
:hx-swap "outerHTML" :hx-push-url "true"
|
||||
(raw! icon-html)
|
||||
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
icon
|
||||
(span :class "text-stone-700" name)
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
31
blog/sx/handlers/link-card.sx
Normal file
31
blog/sx/handlers/link-card.sx
Normal file
@@ -0,0 +1,31 @@
|
||||
;; Blog link-card fragment handler
|
||||
;;
|
||||
;; Renders link-card(s) for blog posts by slug.
|
||||
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
|
||||
|
||||
(defhandler link-card (&key slug keys)
|
||||
(if keys
|
||||
(let ((slugs (split keys ",")))
|
||||
(<> (map (fn (s)
|
||||
(let ((post (query "blog" "post-by-slug" :slug (trim s))))
|
||||
(when post
|
||||
(<> (str "<!-- fragment:" (trim s) " -->")
|
||||
(~link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
:icon "fas fa-file-alt"
|
||||
:subtitle (or (get post "custom_excerpt") (get post "excerpt"))
|
||||
:detail (get post "published_at_display")
|
||||
:data-app "blog"))))) slugs)))
|
||||
(when slug
|
||||
(let ((post (query "blog" "post-by-slug" :slug slug)))
|
||||
(when post
|
||||
(~link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
:icon "fas fa-file-alt"
|
||||
:subtitle (or (get post "custom_excerpt") (get post "excerpt"))
|
||||
:detail (get post "published_at_display")
|
||||
:data-app "blog"))))))
|
||||
80
blog/sx/handlers/nav-tree.sx
Normal file
80
blog/sx/handlers/nav-tree.sx
Normal file
@@ -0,0 +1,80 @@
|
||||
;; Blog nav-tree fragment handler
|
||||
;;
|
||||
;; Renders the full scrollable navigation menu bar with app icons.
|
||||
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.
|
||||
|
||||
(defhandler nav-tree (&key app_name path)
|
||||
(let ((app (or app_name ""))
|
||||
(cur-path (or path "/"))
|
||||
(first-seg (first (filter (fn (s) (not (empty? s)))
|
||||
(split (trim cur-path) "/"))))
|
||||
(items (nav-tree))
|
||||
(nav-cls "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm")
|
||||
|
||||
;; App slug → URL mapping
|
||||
(app-slugs (dict
|
||||
:cart (app-url "cart" "/")
|
||||
:market (app-url "market" "/")
|
||||
:events (app-url "events" "/")
|
||||
:federation (app-url "federation" "/")
|
||||
:account (app-url "account" "/")
|
||||
:artdag (app-url "artdag" "/"))))
|
||||
|
||||
(let ((item-sxs
|
||||
(<>
|
||||
;; Nav items from DB
|
||||
(map (fn (item)
|
||||
(let ((item-slug (or (get item "slug") ""))
|
||||
(href (or (get app-slugs item-slug)
|
||||
(app-url "blog" (str "/" item-slug "/"))))
|
||||
(selected (or (= item-slug (or first-seg ""))
|
||||
(= item-slug app))))
|
||||
(~blog-nav-item-link
|
||||
:href href
|
||||
:hx-get href
|
||||
:selected (if selected "true" "false")
|
||||
:nav-cls nav-cls
|
||||
:img (~img-or-placeholder
|
||||
:src (get item "feature_image")
|
||||
:alt (or (get item "label") item-slug)
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
:label (or (get item "label") item-slug)))) items)
|
||||
|
||||
;; Hardcoded artdag link
|
||||
(~blog-nav-item-link
|
||||
:href (app-url "artdag" "/")
|
||||
:hx-get (app-url "artdag" "/")
|
||||
:selected (if (or (= "artdag" (or first-seg ""))
|
||||
(= "artdag" app)) "true" "false")
|
||||
:nav-cls nav-cls
|
||||
:img (~img-or-placeholder
|
||||
:src nil :alt "art-dag"
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
:label "art-dag")))
|
||||
|
||||
;; Scroll wrapper IDs + hyperscript
|
||||
(arrow-cls "scrolling-menu-arrow-menu-items-container")
|
||||
(cid "menu-items-container")
|
||||
(left-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft - 200"))
|
||||
(scroll-hs (str "on scroll "
|
||||
"set cls to '" arrow-cls "' "
|
||||
"set arrows to document.getElementsByClassName(cls) "
|
||||
"set show to (window.innerWidth >= 640 and "
|
||||
"my.scrollWidth > my.clientWidth) "
|
||||
"repeat for arrow in arrows "
|
||||
"if show remove .hidden from arrow add .flex to arrow "
|
||||
"else add .hidden to arrow remove .flex from arrow end "
|
||||
"end"))
|
||||
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
|
||||
|
||||
(if (empty? items)
|
||||
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~scroll-nav-wrapper
|
||||
:wrapper-id "menu-items-nav-wrapper"
|
||||
:container-id cid
|
||||
:arrow-cls arrow-cls
|
||||
:left-hs left-hs
|
||||
:scroll-hs scroll-hs
|
||||
:right-hs right-hs
|
||||
:items item-sxs
|
||||
:oob true)))))
|
||||
@@ -1,11 +1,8 @@
|
||||
;; Blog header components
|
||||
|
||||
(defcomp ~blog-header-label ()
|
||||
(div))
|
||||
|
||||
(defcomp ~blog-container-nav (&key container-nav-html)
|
||||
(defcomp ~blog-container-nav (&key container-nav)
|
||||
(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" (raw! container-nav-html)))
|
||||
:id "entries-calendars-nav-wrapper" container-nav))
|
||||
|
||||
(defcomp ~blog-admin-label ()
|
||||
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
|
||||
253
blog/sx/index.sx
Normal file
253
blog/sx/index.sx
Normal file
@@ -0,0 +1,253 @@
|
||||
;; Blog index components
|
||||
|
||||
(defcomp ~blog-no-pages ()
|
||||
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
|
||||
|
||||
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
||||
(div :class "flex justify-center gap-1 px-3 pt-3"
|
||||
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts")
|
||||
(a :href pages-href :sx-get pages-href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
|
||||
|
||||
(defcomp ~blog-main-panel-pages (&key tabs cards)
|
||||
(<> tabs
|
||||
(div :class "max-w-full px-3 py-3 space-y-3" cards)
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
|
||||
(<> tabs
|
||||
toggle
|
||||
(div :class grid-cls cards)
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
|
||||
(<> search
|
||||
action-buttons
|
||||
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
|
||||
tag-groups-filter
|
||||
authors-filter)
|
||||
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven composition defcomps — replace Python sx_components functions
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Helper: CSS class for filter item based on selection state
|
||||
(defcomp ~blog-filter-cls (&key is-on)
|
||||
;; Returns nothing — use inline (if is-on ...) instead
|
||||
nil)
|
||||
|
||||
;; Blog index main content — replaces _blog_main_panel_sx
|
||||
(defcomp ~blog-index-main-content (&key content-type view cards page total-pages
|
||||
current-local-href hx-select blog-url-base)
|
||||
(let* ((posts-href (str blog-url-base "/index"))
|
||||
(pages-href (str posts-href "?type=pages"))
|
||||
(posts-cls (if (not (= content-type "pages"))
|
||||
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))
|
||||
(pages-cls (if (= content-type "pages")
|
||||
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
|
||||
(if (= content-type "pages")
|
||||
;; Pages listing
|
||||
(~blog-main-panel-pages
|
||||
:tabs (~blog-content-type-tabs
|
||||
:posts-href posts-href :pages-href pages-href
|
||||
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||
:cards (<>
|
||||
(map (lambda (card)
|
||||
(~blog-page-card
|
||||
:href (get card "href") :hx-select hx-select
|
||||
:title (get card "title")
|
||||
:has-calendar (get card "has_calendar")
|
||||
:has-market (get card "has_market")
|
||||
:pub-timestamp (get card "pub_timestamp")
|
||||
:feature-image (get card "feature_image")
|
||||
:excerpt (get card "excerpt")))
|
||||
(or cards (list)))
|
||||
(if (< page total-pages)
|
||||
(~sentinel-simple
|
||||
:id (str "sentinel-" page "-d")
|
||||
:next-url (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?")
|
||||
"page=" (+ page 1)))
|
||||
(if (not (empty? (or cards (list))))
|
||||
(~end-of-results)
|
||||
(~blog-no-pages)))))
|
||||
;; Posts listing
|
||||
(let* ((grid-cls (if (= view "tile")
|
||||
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||
"max-w-full px-3 py-3 space-y-3"))
|
||||
(list-href current-local-href)
|
||||
(tile-href (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?") "view=tile"))
|
||||
(list-cls (if (not (= view "tile"))
|
||||
"bg-stone-200 text-stone-800"
|
||||
"text-stone-400 hover:text-stone-600"))
|
||||
(tile-cls (if (= view "tile")
|
||||
"bg-stone-200 text-stone-800"
|
||||
"text-stone-400 hover:text-stone-600")))
|
||||
(~blog-main-panel-posts
|
||||
:tabs (~blog-content-type-tabs
|
||||
:posts-href posts-href :pages-href pages-href
|
||||
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||
:toggle (~view-toggle
|
||||
:list-href list-href :tile-href tile-href :hx-select hx-select
|
||||
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
|
||||
:list-svg (~list-svg) :tile-svg (~tile-svg))
|
||||
:grid-cls grid-cls
|
||||
:cards (<>
|
||||
(map (lambda (card)
|
||||
(if (= view "tile")
|
||||
(~blog-card-tile
|
||||
:href (get card "href") :hx-select hx-select
|
||||
:feature-image (get card "feature_image")
|
||||
:title (get card "title") :is-draft (get card "is_draft")
|
||||
:publish-requested (get card "publish_requested")
|
||||
:status-timestamp (get card "status_timestamp")
|
||||
:excerpt (get card "excerpt")
|
||||
:tags (get card "tags") :authors (get card "authors"))
|
||||
(~blog-card
|
||||
:slug (get card "slug") :href (get card "href") :hx-select hx-select
|
||||
:title (get card "title") :feature-image (get card "feature_image")
|
||||
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
|
||||
:publish-requested (get card "publish_requested")
|
||||
:status-timestamp (get card "status_timestamp")
|
||||
:has-like (get card "has_like") :liked (get card "liked")
|
||||
:like-url (get card "like_url") :csrf-token (get card "csrf_token")
|
||||
:tags (get card "tags") :authors (get card "authors")
|
||||
:widget (get card "widget"))))
|
||||
(or cards (list)))
|
||||
(~blog-index-sentinel
|
||||
:page page :total-pages total-pages
|
||||
:current-local-href current-local-href)))))))
|
||||
|
||||
;; Sentinel for blog index infinite scroll
|
||||
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href)
|
||||
(when (< page total-pages)
|
||||
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
|
||||
(~sentinel-desktop
|
||||
:id (str "sentinel-" page "-d")
|
||||
:next-url next-url
|
||||
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
|
||||
|
||||
;; Blog index action buttons — replaces _action_buttons_sx
|
||||
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href)
|
||||
(~blog-action-buttons-wrapper
|
||||
:inner (<>
|
||||
(when is-admin
|
||||
(<>
|
||||
(~blog-action-button
|
||||
:href new-post-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
|
||||
(~blog-action-button
|
||||
:href new-page-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
|
||||
(when (and has-user (or draft-count drafts))
|
||||
(if drafts
|
||||
(~blog-drafts-button
|
||||
:href current-local-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
|
||||
(let* ((on-href (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
|
||||
(~blog-drafts-button-amber
|
||||
:href on-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
|
||||
|
||||
;; Tag groups filter — replaces _tag_groups_filter_sx
|
||||
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select)
|
||||
(~blog-filter-nav
|
||||
:items (<>
|
||||
(~blog-filter-any-topic
|
||||
:cls (if is-any-group
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||
:hx-select hx-select)
|
||||
(map (lambda (grp)
|
||||
(let* ((is-on (get grp "is_selected"))
|
||||
(cls (if is-on
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(fi (get grp "feature_image"))
|
||||
(colour (get grp "colour"))
|
||||
(name (get grp "name"))
|
||||
(icon (if fi
|
||||
(~blog-filter-group-icon-image :src fi :name name)
|
||||
(~blog-filter-group-icon-color
|
||||
:style (if colour
|
||||
(str "background-color: " colour "; color: white;")
|
||||
"background-color: #e7e5e4; color: #57534e;")
|
||||
:initial (slice (or name "?") 0 1)))))
|
||||
(~blog-filter-group-li
|
||||
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
|
||||
:hx-select hx-select :icon icon
|
||||
:name name :count (str (get grp "post_count")))))
|
||||
(or tag-groups (list))))))
|
||||
|
||||
;; Authors filter — replaces _authors_filter_sx
|
||||
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select)
|
||||
(~blog-filter-nav
|
||||
:items (<>
|
||||
(~blog-filter-any-author
|
||||
:cls (if is-any-author
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||
:hx-select hx-select)
|
||||
(map (lambda (a)
|
||||
(let* ((is-on (get a "is_selected"))
|
||||
(cls (if is-on
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(img (get a "profile_image")))
|
||||
(~blog-filter-author-li
|
||||
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
|
||||
:hx-select hx-select
|
||||
:icon (when img (~blog-filter-author-icon :src img :name (get a "name")))
|
||||
:name (get a "name")
|
||||
:count (str (get a "published_post_count")))))
|
||||
(or authors (list))))))
|
||||
|
||||
;; Blog index aside — replaces _blog_aside_sx
|
||||
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href
|
||||
tag-groups authors is-any-group is-any-author)
|
||||
(~blog-aside
|
||||
:search (~search-desktop)
|
||||
:action-buttons (~blog-index-actions
|
||||
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||
:draft-count draft-count :drafts drafts
|
||||
:new-post-href new-post-href :new-page-href new-page-href
|
||||
:current-local-href current-local-href)
|
||||
:tag-groups-filter (~blog-index-tag-groups-filter
|
||||
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||
:authors-filter (~blog-index-authors-filter
|
||||
:authors authors :is-any-author is-any-author :hx-select hx-select)))
|
||||
|
||||
;; Blog index mobile filter — replaces _blog_filter_sx
|
||||
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href
|
||||
tag-groups authors is-any-group is-any-author
|
||||
tg-summary au-summary)
|
||||
(~mobile-filter
|
||||
:filter-summary (<>
|
||||
(~search-mobile)
|
||||
(when (not (= tg-summary ""))
|
||||
(~blog-filter-summary :text tg-summary))
|
||||
(when (not (= au-summary ""))
|
||||
(~blog-filter-summary :text au-summary)))
|
||||
:action-buttons (~blog-index-actions
|
||||
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||
:draft-count draft-count :drafts drafts
|
||||
:new-post-href new-post-href :new-page-href new-page-href
|
||||
:current-local-href current-local-href)
|
||||
:filter-details (<>
|
||||
(~blog-index-tag-groups-filter
|
||||
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||
(~blog-index-authors-filter
|
||||
:authors authors :is-any-author is-any-author :hx-select hx-select))))
|
||||
153
blog/sx/kg_cards.sx
Normal file
153
blog/sx/kg_cards.sx
Normal file
@@ -0,0 +1,153 @@
|
||||
;; KG card components — Ghost/Koenig-compatible card rendering
|
||||
;; Produces same HTML structure as lexical_renderer.py so cards.css works unchanged.
|
||||
;; Used by both display pipeline and block editor.
|
||||
|
||||
;; @css kg-card kg-image-card kg-width-wide kg-width-full kg-gallery-card kg-gallery-container kg-gallery-row kg-gallery-image kg-embed-card kg-bookmark-card kg-bookmark-container kg-bookmark-content kg-bookmark-title kg-bookmark-description kg-bookmark-metadata kg-bookmark-icon kg-bookmark-author kg-bookmark-publisher kg-bookmark-thumbnail kg-callout-card kg-callout-emoji kg-callout-text kg-button-card kg-btn kg-btn-accent kg-toggle-card kg-toggle-heading kg-toggle-heading-text kg-toggle-card-icon kg-toggle-content kg-audio-card kg-audio-thumbnail kg-audio-player-container kg-audio-title kg-audio-player kg-audio-play-icon kg-audio-current-time kg-audio-time kg-audio-seek-slider kg-audio-playback-rate kg-audio-unmute-icon kg-audio-volume-slider kg-video-card kg-video-container kg-file-card kg-file-card-container kg-file-card-contents kg-file-card-title kg-file-card-filesize kg-file-card-icon kg-file-card-caption kg-align-center kg-align-left kg-callout-card-grey kg-callout-card-white kg-callout-card-blue kg-callout-card-green kg-callout-card-yellow kg-callout-card-red kg-callout-card-pink kg-callout-card-purple kg-callout-card-accent kg-html-card kg-md-card placeholder
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Image card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-image (&key src alt caption width href)
|
||||
(figure :class (str "kg-card kg-image-card"
|
||||
(if (= width "wide") " kg-width-wide"
|
||||
(if (= width "full") " kg-width-full" "")))
|
||||
(if href
|
||||
(a :href href (img :src src :alt (or alt "") :loading "lazy"))
|
||||
(img :src src :alt (or alt "") :loading "lazy"))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Gallery card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-gallery (&key images caption)
|
||||
(figure :class "kg-card kg-gallery-card kg-width-wide"
|
||||
(div :class "kg-gallery-container"
|
||||
(map (lambda (row)
|
||||
(div :class "kg-gallery-row"
|
||||
(map (lambda (img-data)
|
||||
(figure :class "kg-gallery-image"
|
||||
(img :src (get img-data "src") :alt (or (get img-data "alt") "") :loading "lazy")
|
||||
(when (get img-data "caption") (figcaption (get img-data "caption")))))
|
||||
row)))
|
||||
images))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTML card — wraps user-pasted HTML so the editor can identify the block.
|
||||
;; Content is native sx children (no longer an opaque HTML string).
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-html (&rest children)
|
||||
(div :class "kg-card kg-html-card" children))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Markdown card — rendered markdown content, editor can identify the block.
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-md (&rest children)
|
||||
(div :class "kg-card kg-md-card" children))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Embed card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-embed (&key html caption)
|
||||
(figure :class "kg-card kg-embed-card"
|
||||
(~rich-text :html html)
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Bookmark card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-bookmark (&key url title description icon author publisher thumbnail caption)
|
||||
(figure :class "kg-card kg-bookmark-card"
|
||||
(a :class "kg-bookmark-container" :href url
|
||||
(div :class "kg-bookmark-content"
|
||||
(div :class "kg-bookmark-title" (or title ""))
|
||||
(div :class "kg-bookmark-description" (or description ""))
|
||||
(when (or icon author publisher)
|
||||
(span :class "kg-bookmark-metadata"
|
||||
(when icon (img :class "kg-bookmark-icon" :src icon :alt ""))
|
||||
(when author (span :class "kg-bookmark-author" author))
|
||||
(when publisher (span :class "kg-bookmark-publisher" publisher)))))
|
||||
(when thumbnail
|
||||
(div :class "kg-bookmark-thumbnail"
|
||||
(img :src thumbnail :alt ""))))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Callout card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-callout (&key color emoji content)
|
||||
(div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
|
||||
(when emoji (div :class "kg-callout-emoji" emoji))
|
||||
(div :class "kg-callout-text" (or content ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Button card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-button (&key url text alignment)
|
||||
(div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
|
||||
(a :href url :class "kg-btn kg-btn-accent" (or text ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Toggle card (accordion)
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-toggle (&key heading content)
|
||||
(div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
|
||||
(div :class "kg-toggle-heading"
|
||||
(h4 :class "kg-toggle-heading-text" (or heading ""))
|
||||
(button :class "kg-toggle-card-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 14 14\"><path d=\"M7 0a.5.5 0 0 1 .5.5v6h6a.5.5 0 1 1 0 1h-6v6a.5.5 0 1 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 7 0Z\" fill=\"currentColor\"/></svg>")))
|
||||
(div :class "kg-toggle-content" (or content ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Audio card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-audio (&key src title duration thumbnail)
|
||||
(div :class "kg-card kg-audio-card"
|
||||
(if thumbnail
|
||||
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
|
||||
(div :class "kg-audio-thumbnail placeholder"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M2 12C2 6.48 6.48 2 12 2s10 4.48 10 10-4.48 10-10 10S2 17.52 2 12zm7.5 5.25L16 12 9.5 6.75v10.5z\" fill=\"currentColor\"/></svg>")))
|
||||
(div :class "kg-audio-player-container"
|
||||
(div :class "kg-audio-title" (or title ""))
|
||||
(div :class "kg-audio-player"
|
||||
(button :class "kg-audio-play-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\" fill=\"currentColor\"/></svg>"))
|
||||
(div :class "kg-audio-current-time" "0:00")
|
||||
(div :class "kg-audio-time" (str "/ " (or duration "0:00")))
|
||||
(input :type "range" :class "kg-audio-seek-slider" :max "100" :value "0")
|
||||
(button :class "kg-audio-playback-rate" "1×")
|
||||
(button :class "kg-audio-unmute-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\" fill=\"currentColor\"/></svg>"))
|
||||
(input :type "range" :class "kg-audio-volume-slider" :max "100" :value "100")))
|
||||
(audio :src src :preload "metadata")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Video card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-video (&key src caption width thumbnail loop)
|
||||
(figure :class (str "kg-card kg-video-card"
|
||||
(if (= width "wide") " kg-width-wide"
|
||||
(if (= width "full") " kg-width-full" "")))
|
||||
(div :class "kg-video-container"
|
||||
(video :src src :controls true :preload "metadata"
|
||||
:poster (or thumbnail nil) :loop (or loop nil)))
|
||||
(when caption (figcaption caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; File card
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-file (&key src filename title filesize caption)
|
||||
(div :class "kg-card kg-file-card"
|
||||
(a :class "kg-file-card-container" :href src :download (or filename "")
|
||||
(div :class "kg-file-card-contents"
|
||||
(div :class "kg-file-card-title" (or title filename ""))
|
||||
(when filesize (div :class "kg-file-card-filesize" filesize)))
|
||||
(div :class "kg-file-card-icon"
|
||||
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z\" fill=\"currentColor\"/></svg>")))
|
||||
(when caption (div :class "kg-file-card-caption" caption))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Paywall marker
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-paywall ()
|
||||
(~rich-text :html "<!--members-only-->"))
|
||||
26
blog/sx/menu_items.sx
Normal file
26
blog/sx/menu_items.sx
Normal file
@@ -0,0 +1,26 @@
|
||||
;; Menu item form and page search components
|
||||
|
||||
(defcomp ~page-search-item (&key id title slug feature-image)
|
||||
(div :class "flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
|
||||
:data-page-id id :data-page-title title :data-page-slug slug
|
||||
:data-page-image (or feature-image "")
|
||||
(if feature-image
|
||||
(img :src feature-image :alt title :class "w-10 h-10 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-10 h-10 rounded-full bg-stone-200 flex-shrink-0"))
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" title)
|
||||
(div :class "text-xs text-stone-500 truncate" slug))))
|
||||
|
||||
(defcomp ~page-search-results (&key items sentinel)
|
||||
(div :class "border border-stone-200 rounded-md max-h-64 overflow-y-auto"
|
||||
items sentinel))
|
||||
|
||||
(defcomp ~page-search-sentinel (&key url query next-page)
|
||||
(div :sx-get url :sx-trigger "intersect once" :sx-swap "outerHTML"
|
||||
:sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}")
|
||||
:class "p-3 text-center text-sm text-stone-400"
|
||||
(i :class "fa fa-spinner fa-spin") " Loading more..."))
|
||||
|
||||
(defcomp ~page-search-empty (&key query)
|
||||
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
|
||||
(str "No pages found matching \"" query "\"")))
|
||||
@@ -1,8 +1,8 @@
|
||||
;; Blog settings panel components (features, markets, associated entries)
|
||||
|
||||
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
||||
(form :hx-put features-url :hx-target "#features-panel" :hx-swap "outerHTML"
|
||||
:hx-headers "{\"Content-Type\": \"application/json\"}" :hx-ext "json-enc" :class "space-y-3"
|
||||
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
|
||||
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3"
|
||||
(label :class "flex items-center gap-3 cursor-pointer"
|
||||
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
||||
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
||||
@@ -18,38 +18,16 @@
|
||||
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
||||
" Market \u2014 enable product catalog on this page"))))
|
||||
|
||||
(defcomp ~blog-sumup-connected ()
|
||||
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
|
||||
|
||||
(defcomp ~blog-sumup-key-hint ()
|
||||
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
|
||||
|
||||
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint-html checkout-prefix connected-html)
|
||||
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
|
||||
(div :class "mt-4 pt-4 border-t border-stone-100"
|
||||
(h4 :class "text-sm font-medium text-stone-700"
|
||||
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
||||
(p :class "text-xs text-stone-400 mt-1 mb-3"
|
||||
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
||||
(form :hx-put sumup-url :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3"
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
||||
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
||||
(input :type "password" :name "api_key" :value "" :placeholder placeholder
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
|
||||
(raw! key-hint-html))
|
||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
||||
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
|
||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(button :type "submit"
|
||||
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
||||
"Save SumUp Settings")
|
||||
(raw! connected-html))))
|
||||
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code
|
||||
:placeholder placeholder :sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix :panel-id "features-panel")))
|
||||
|
||||
(defcomp ~blog-features-panel (&key form-html sumup-html)
|
||||
(defcomp ~blog-features-panel (&key form sumup)
|
||||
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
||||
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
|
||||
(raw! form-html) (raw! sumup-html)))
|
||||
form sumup))
|
||||
|
||||
;; Markets panel
|
||||
|
||||
@@ -57,57 +35,94 @@
|
||||
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
|
||||
(div (span :class "font-medium" name)
|
||||
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
|
||||
(button :hx-delete delete-url :hx-target "#markets-panel" :hx-swap "outerHTML"
|
||||
:hx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
|
||||
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
|
||||
:sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
|
||||
|
||||
(defcomp ~blog-markets-list (&key items-html)
|
||||
(ul :class "space-y-2 mb-4" (raw! items-html)))
|
||||
(defcomp ~blog-markets-list (&key items)
|
||||
(ul :class "space-y-2 mb-4" items))
|
||||
|
||||
(defcomp ~blog-markets-empty ()
|
||||
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
|
||||
|
||||
(defcomp ~blog-markets-panel (&key list-html create-url)
|
||||
(defcomp ~blog-markets-panel (&key list create-url)
|
||||
(div :id "markets-panel"
|
||||
(h3 :class "text-lg font-semibold mb-3" "Markets")
|
||||
(raw! list-html)
|
||||
(form :hx-post create-url :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2"
|
||||
list
|
||||
(form :sx-post create-url :sx-target "#markets-panel" :sx-swap "outerHTML" :class "flex gap-2"
|
||||
(input :type "text" :name "name" :placeholder "Market name" :required ""
|
||||
:class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")
|
||||
(button :type "submit"
|
||||
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven composition defcomps — replace Python render_* functions
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Features panel composition — replaces render_features_panel
|
||||
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
|
||||
show-sumup sumup-url merchant-code placeholder
|
||||
sumup-configured checkout-prefix)
|
||||
(~blog-features-panel
|
||||
:form (~blog-features-form
|
||||
:features-url features-url
|
||||
:calendar-checked calendar-checked
|
||||
:market-checked market-checked
|
||||
:hs-trigger "on change trigger submit on closest <form/>")
|
||||
:sumup (when show-sumup
|
||||
(~blog-sumup-form
|
||||
:sumup-url sumup-url
|
||||
:merchant-code merchant-code
|
||||
:placeholder placeholder
|
||||
:sumup-configured sumup-configured
|
||||
:checkout-prefix checkout-prefix))))
|
||||
|
||||
;; Markets panel composition — replaces render_markets_panel
|
||||
(defcomp ~blog-markets-panel-content (&key markets create-url)
|
||||
(~blog-markets-panel
|
||||
:list (if (empty? (or markets (list)))
|
||||
(~blog-markets-empty)
|
||||
(~blog-markets-list
|
||||
:items (map (lambda (m)
|
||||
(~blog-market-item
|
||||
:name (get m "name")
|
||||
:slug (get m "slug")
|
||||
:delete-url (get m "delete_url")
|
||||
:confirm-text (str "Delete market '" (get m "name") "'?")))
|
||||
(or markets (list)))))
|
||||
:create-url create-url))
|
||||
|
||||
;; Associated entries
|
||||
|
||||
(defcomp ~blog-entry-image (&key src title)
|
||||
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
||||
|
||||
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img-html name date-str)
|
||||
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img name date-str)
|
||||
(button :type "button"
|
||||
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
|
||||
:data-confirm "" :data-confirm-title "Remove entry?"
|
||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
||||
:data-confirm-confirm-text "Yes, remove it"
|
||||
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
||||
:hx-post toggle-url :hx-trigger "confirmed"
|
||||
:hx-target "#associated-entries-list" :hx-swap "outerHTML"
|
||||
:hx-headers hx-headers
|
||||
:sx-post toggle-url :sx-trigger "confirmed"
|
||||
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers
|
||||
:_ "on htmx:afterRequest trigger entryToggled on body"
|
||||
(div :class "flex items-center justify-between gap-3"
|
||||
(raw! img-html)
|
||||
img
|
||||
(div :class "flex-1"
|
||||
(div :class "font-medium text-sm" name)
|
||||
(div :class "text-xs text-stone-600 mt-1" date-str))
|
||||
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
|
||||
|
||||
(defcomp ~blog-associated-entries-content (&key items-html)
|
||||
(div :class "space-y-1" (raw! items-html)))
|
||||
(defcomp ~blog-associated-entries-content (&key items)
|
||||
(div :class "space-y-1" items))
|
||||
|
||||
(defcomp ~blog-associated-entries-empty ()
|
||||
(div :class "text-sm text-stone-400"
|
||||
"No entries associated yet. Browse calendars below to add entries."))
|
||||
|
||||
(defcomp ~blog-associated-entries-panel (&key content-html)
|
||||
(defcomp ~blog-associated-entries-panel (&key content)
|
||||
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
||||
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
||||
(raw! content-html)))
|
||||
content))
|
||||
1121
blog/sxc/pages/__init__.py
Normal file
1121
blog/sxc/pages/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user