Compare commits
152 Commits
main
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57ae97f17b | ||
|
|
08c58d34f9 | ||
|
|
89d7767a59 | ||
|
|
06dea73557 | ||
|
|
5b63d9fb93 | ||
|
|
bcbbc20c52 | ||
|
|
3c517fd4ca | ||
|
|
2bdde5cdbf | ||
|
|
1f697b2961 | ||
|
|
e5b02f1c44 | ||
|
|
288b3caf7f | ||
|
|
3e11bef978 | ||
|
|
b12c8788c7 | ||
|
|
e9a59e5f93 | ||
|
|
5fc758d3c1 | ||
|
|
e243d858fd | ||
|
|
ec1bab869c | ||
|
|
f2685771c5 | ||
|
|
c4dee48d17 | ||
|
|
49e7739853 | ||
|
|
3d18f3b61f | ||
|
|
525ed3d9a3 | ||
|
|
9ab9350271 | ||
|
|
2679b5fb6c | ||
|
|
69ab9ad0d9 | ||
|
|
47ebaa0eec | ||
|
|
fd24ab5030 | ||
|
|
4cc00c763c | ||
|
|
b96800c71a | ||
|
|
5f97c7cf46 | ||
|
|
ff5ce235a4 | ||
|
|
c1c2129772 | ||
|
|
5d824902ba | ||
|
|
6ae56daf04 | ||
|
|
10c1873358 | ||
|
|
957e3c3fd3 | ||
|
|
8c084a8470 | ||
|
|
7fe2486631 | ||
|
|
971a60ac63 | ||
|
|
416650e642 | ||
|
|
f93bc6f987 | ||
|
|
fe8e477781 | ||
|
|
835f406546 | ||
|
|
20e931a934 | ||
|
|
1a3bd45dce | ||
|
|
17cedb4ade | ||
|
|
bde64bcc20 | ||
|
|
925f9a9df2 | ||
|
|
e6d78c1031 | ||
|
|
f5e7e29c3b | ||
|
|
7bade78dc6 | ||
|
|
527003b183 | ||
|
|
2fb2357caf | ||
|
|
fb1cef6cb5 | ||
|
|
85fd9d9f60 | ||
|
|
989610b533 | ||
|
|
ce587b9e43 | ||
|
|
82968a366f | ||
|
|
065147569c | ||
|
|
d76f985902 | ||
|
|
7453ff845c | ||
|
|
1e8b72e36d | ||
|
|
3f44d513c0 | ||
|
|
2752f735ba | ||
|
|
b9b8bbd73d | ||
|
|
9515e411fa | ||
|
|
859cf52b2b | ||
|
|
48a381eabb | ||
|
|
49a9fd7552 | ||
|
|
1a8a5f4487 | ||
|
|
78fb9d8dd8 | ||
|
|
9182c8d0b5 | ||
|
|
5e9ab507be | ||
|
|
98ab24f517 | ||
|
|
324cd9cf5b | ||
|
|
36c33d9ce2 | ||
|
|
4c44fc64c5 | ||
|
|
8cc17e195d | ||
|
|
ecb8639829 | ||
|
|
a02765dffa | ||
|
|
e467946f1d | ||
|
|
fe3bc9d893 | ||
|
|
c3c878f781 | ||
|
|
ceacf7a56e | ||
|
|
0d18fd8fd9 | ||
|
|
582882205f | ||
|
|
507200893d | ||
|
|
9d6a458115 | ||
|
|
346089973f | ||
|
|
80e4f21b0b | ||
|
|
954b6cc06a | ||
|
|
85acc68840 | ||
|
|
c40769d24a | ||
|
|
23fe8c233e | ||
|
|
7f52f59fe0 | ||
|
|
53dff0d41b | ||
|
|
234a5f797d | ||
|
|
fb93af067c | ||
|
|
a8e0d8f257 | ||
|
|
2dc9bf220b | ||
|
|
89112d0cec | ||
|
|
5c203cb99c | ||
|
|
f625c42118 | ||
|
|
7ec38b87f8 | ||
|
|
4f6e5d234d | ||
|
|
8af7c69090 | ||
|
|
bb60835c58 | ||
|
|
909ae0e2d6 | ||
|
|
5301459201 | ||
|
|
6a332b95c0 | ||
|
|
08e441f2ff | ||
|
|
895c323968 | ||
|
|
9dc5877fc9 | ||
|
|
f9e39333bf | ||
|
|
6f063665b0 | ||
|
|
e6ccdc423d | ||
|
|
52a4f4ad43 | ||
|
|
f364448131 | ||
|
|
4155df7e7c | ||
|
|
e1f4471002 | ||
|
|
2efc05957e | ||
|
|
c537770172 | ||
|
|
1ea2950310 | ||
|
|
aa06082ad2 | ||
|
|
4d31123635 | ||
|
|
122bf90714 | ||
|
|
e6409128d3 | ||
|
|
d2fa8c17bb | ||
|
|
803ccfb58d | ||
|
|
15eda69639 | ||
|
|
d2e6dd8b1d | ||
|
|
dd7fbc89ce | ||
|
|
23b1e35eac | ||
|
|
45b748eb6d | ||
|
|
43bc03836d | ||
|
|
7d20f67d99 | ||
|
|
6fb60d43c6 | ||
|
|
a24e5c6407 | ||
|
|
75a5d520e8 | ||
|
|
70ef1910c1 | ||
|
|
05d9e70e8a | ||
|
|
da3481196b | ||
|
|
9d13230465 | ||
|
|
73160377f1 | ||
|
|
977e8d99af | ||
|
|
bae37d97ae | ||
|
|
bebb81a0f4 | ||
|
|
3e1aa7197b | ||
|
|
9b180b364b | ||
|
|
9e6f138f45 | ||
|
|
a1eaba5119 | ||
|
|
a01016d8d5 |
@@ -2,13 +2,13 @@ name: Build and Deploy
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, decoupling]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: registry.rose-ash.com:5000
|
REGISTRY: registry.rose-ash.com:5000
|
||||||
IMAGE: blog
|
IMAGE: blog
|
||||||
REPO_DIR: /root/rose-ash/blog
|
REPO_DIR: /root/rose-ash/blog
|
||||||
COOP_DIR: /root/coop
|
COOP_DIR: /root/rose-ash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -36,9 +36,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ssh "root@$DEPLOY_HOST" "
|
ssh "root@$DEPLOY_HOST" "
|
||||||
cd ${{ env.REPO_DIR }}
|
cd ${{ env.REPO_DIR }}
|
||||||
git fetch origin main
|
git fetch origin ${{ github.ref_name }}
|
||||||
git reset --hard origin/main
|
git reset --hard origin/${{ github.ref_name }}
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
# Clean ALL sibling dirs (including stale self-copies from previous runs)
|
||||||
|
for sibling in blog market cart events federation; do
|
||||||
|
rm -rf \$sibling
|
||||||
|
done
|
||||||
|
# Copy non-self sibling models for cross-domain imports
|
||||||
|
for sibling in blog market cart events federation; do
|
||||||
|
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
|
||||||
|
repo=/root/rose-ash/\$sibling
|
||||||
|
if [ -d \$repo/.git ]; then
|
||||||
|
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
|
||||||
|
mkdir -p \$sibling
|
||||||
|
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
"
|
"
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
|
|||||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
|||||||
[submodule "shared_lib"]
|
[submodule "shared"]
|
||||||
path = shared_lib
|
path = shared
|
||||||
url = https://git.rose-ash.com/coop/shared.git
|
url = https://git.rose-ash.com/coop/shared.git
|
||||||
|
branch = decoupling
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -3,9 +3,9 @@
|
|||||||
# ---------- Stage 1: Build editor JS/CSS ----------
|
# ---------- Stage 1: Build editor JS/CSS ----------
|
||||||
FROM node:20-slim AS editor-build
|
FROM node:20-slim AS editor-build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY shared_lib/editor/package.json shared_lib/editor/package-lock.json* ./
|
COPY shared/editor/package.json shared/editor/package-lock.json* ./
|
||||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||||
COPY shared_lib/editor/ ./
|
COPY shared/editor/ ./
|
||||||
RUN NODE_ENV=production node build.mjs
|
RUN NODE_ENV=production node build.mjs
|
||||||
|
|
||||||
# ---------- Stage 2: Python runtime ----------
|
# ---------- Stage 2: Python runtime ----------
|
||||||
@@ -13,6 +13,7 @@ FROM python:3.11-slim AS base
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
APP_PORT=8000 \
|
APP_PORT=8000 \
|
||||||
APP_MODULE=app:app
|
APP_MODULE=app:app
|
||||||
@@ -25,16 +26,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
postgresql-client \
|
postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY shared_lib/requirements.txt ./requirements.txt
|
COPY shared/requirements.txt ./requirements.txt
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Copy built editor assets from stage 1
|
# Copy built editor assets from stage 1
|
||||||
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared_lib/static/scripts/
|
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/
|
||||||
|
|
||||||
# Link app blueprints into the shared library's namespace
|
|
||||||
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
|
|
||||||
|
|
||||||
# ---------- Runtime setup ----------
|
# ---------- Runtime setup ----------
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -1,60 +1,60 @@
|
|||||||
# Blog App
|
# Blog App (Coop)
|
||||||
|
|
||||||
Blog and content management application for the Rose Ash cooperative platform.
|
Blog, authentication, and content management service for the Rose Ash cooperative platform. Handles Ghost CMS integration, user auth, and admin settings.
|
||||||
|
|
||||||
## Overview
|
## Architecture
|
||||||
|
|
||||||
This is the **blog** service extracted from the Rose Ash (Suma Browser) monolith.
|
One of five Quart microservices sharing a single PostgreSQL database:
|
||||||
It handles:
|
|
||||||
|
|
||||||
- **Blog**: Ghost CMS integration for browsing, creating, and editing posts
|
| App | Port | Domain |
|
||||||
- **Auth**: Magic link authentication and user account management
|
|-----|------|--------|
|
||||||
- **Admin/Settings**: Administrative interface and settings management
|
| **blog (coop)** | 8000 | Auth, blog, admin, menus, snippets |
|
||||||
- **Menu Items**: Navigation menu item management
|
| market | 8001 | Product browsing, Suma scraping |
|
||||||
- **Snippets**: Reusable content snippet management
|
| cart | 8002 | Shopping cart, checkout, orders |
|
||||||
- **Internal API**: Server-to-server endpoints for cross-app data sharing
|
| events | 8003 | Calendars, bookings, tickets |
|
||||||
|
| federation | 8004 | ActivityPub, fediverse social |
|
||||||
|
|
||||||
## Tech Stack
|
## Structure
|
||||||
|
|
||||||
- **Quart** (async Flask) with HTMX
|
```
|
||||||
- **SQLAlchemy 2.0** (async) with PostgreSQL
|
app.py # Application factory (create_base_app + blueprints)
|
||||||
- **Redis** for page caching
|
path_setup.py # Adds project root + app dir to sys.path
|
||||||
- **Ghost CMS** for blog content
|
config/app-config.yaml # App URLs, feature flags, SumUp config
|
||||||
|
models/ # Blog-domain models (+ re-export stubs for shared models)
|
||||||
|
bp/ # Blueprints
|
||||||
|
auth/ # Magic link login, account, newsletters
|
||||||
|
blog/ # Post listing, Ghost CMS sync
|
||||||
|
post/ # Single post view and admin
|
||||||
|
admin/ # Settings admin interface
|
||||||
|
menu_items/ # Navigation menu management
|
||||||
|
snippets/ # Reusable content snippets
|
||||||
|
templates/ # Jinja2 templates
|
||||||
|
services/ # register_domain_services() — wires blog + calendar + market + cart
|
||||||
|
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-Domain Communication
|
||||||
|
|
||||||
|
All inter-app communication uses typed service contracts (no HTTP APIs):
|
||||||
|
|
||||||
|
- `services.calendar.*` — calendar/entry queries via CalendarService protocol
|
||||||
|
- `services.market.*` — marketplace queries via MarketService protocol
|
||||||
|
- `services.cart.*` — cart summary via CartService protocol
|
||||||
|
- `services.federation.*` — AP publishing via FederationService protocol
|
||||||
|
- `shared.services.navigation` — site navigation tree
|
||||||
|
|
||||||
|
## Domain Events
|
||||||
|
|
||||||
|
- `auth/routes.py` emits `user.logged_in` via `shared.events.emit_event`
|
||||||
|
- Ghost sync emits `post.published` / `post.updated` for federation
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set environment variables (see .env.example)
|
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||||
export APP_MODULE=app:app
|
export REDIS_URL=redis://localhost:6379/0
|
||||||
|
export SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
# Run migrations
|
alembic -c shared/alembic.ini upgrade head
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
hypercorn app:app --bind 0.0.0.0:8000
|
hypercorn app:app --bind 0.0.0.0:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t blog .
|
|
||||||
docker run -p 8000:8000 --env-file .env blog
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
app.py # Application factory and entry point
|
|
||||||
bp/ # Blueprints
|
|
||||||
auth/ # Authentication (magic links, account)
|
|
||||||
blog/ # Blog listing, Ghost CMS integration
|
|
||||||
post/ # Individual post viewing and admin
|
|
||||||
admin/ # Settings admin interface
|
|
||||||
menu_items/ # Navigation menu management
|
|
||||||
snippets/ # Content snippet management
|
|
||||||
coop_api.py # Internal API endpoints
|
|
||||||
templates/ # Jinja2 templates
|
|
||||||
_types/ # Feature-specific templates
|
|
||||||
entrypoint.sh # Docker entrypoint (migrations + server start)
|
|
||||||
Dockerfile # Container build definition
|
|
||||||
```
|
|
||||||
|
|||||||
0
__init__.py
Normal file
0
__init__.py
Normal file
83
app.py
83
app.py
@@ -1,55 +1,82 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared_lib to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from shared.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
from config import config
|
from shared.config import config
|
||||||
from models import KV
|
from shared.models import KV
|
||||||
|
|
||||||
from suma_browser.app.bp import (
|
from bp import (
|
||||||
register_auth_bp,
|
|
||||||
register_blog_bp,
|
register_blog_bp,
|
||||||
register_admin,
|
register_admin,
|
||||||
register_menu_items,
|
register_menu_items,
|
||||||
register_snippets,
|
register_snippets,
|
||||||
register_coop_api,
|
register_fragments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def coop_context() -> dict:
|
async def blog_context() -> dict:
|
||||||
"""
|
"""
|
||||||
Coop app context processor.
|
Blog app context processor.
|
||||||
|
|
||||||
- menu_items: direct DB query (coop owns this data)
|
- cart_count/cart_total: via cart service (shared DB)
|
||||||
- cart_count/cart_total: fetched from cart internal API
|
- cart_mini_html / auth_menu_html / nav_tree_html: pre-fetched fragments
|
||||||
"""
|
"""
|
||||||
from shared.context import base_context
|
from shared.infrastructure.context import base_context
|
||||||
from suma_browser.app.bp.menu_items.services.menu_items import get_all_menu_items
|
from shared.services.navigation import get_navigation_tree
|
||||||
from shared.internal_api import get as api_get
|
from shared.services.registry import services
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.infrastructure.fragments import fetch_fragments
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
# Coop owns menu_items — query directly
|
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||||
ctx["menu_items"] = await get_all_menu_items(g.s)
|
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||||
|
|
||||||
# Cart data from cart app API
|
# Cart data via service (replaces cross-app HTTP API)
|
||||||
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
|
ident = current_cart_identity()
|
||||||
if cart_data:
|
summary = await services.cart.cart_summary(
|
||||||
ctx["cart_count"] = cart_data.get("count", 0)
|
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||||
ctx["cart_total"] = cart_data.get("total", 0)
|
)
|
||||||
else:
|
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
|
||||||
ctx["cart_count"] = 0
|
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
|
||||||
ctx["cart_total"] = 0
|
|
||||||
|
# Pre-fetch cross-app HTML fragments concurrently
|
||||||
|
# (fetch_fragment auto-skips when inside a fragment request to prevent circular deps)
|
||||||
|
user = getattr(g, "user", None)
|
||||||
|
cart_params = {}
|
||||||
|
if ident["user_id"] is not None:
|
||||||
|
cart_params["user_id"] = ident["user_id"]
|
||||||
|
if ident["session_id"] is not None:
|
||||||
|
cart_params["session_id"] = ident["session_id"]
|
||||||
|
|
||||||
|
auth_params = {"email": user.email} if user else {}
|
||||||
|
nav_params = {"app_name": "blog", "path": request.path}
|
||||||
|
|
||||||
|
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
|
||||||
|
("cart", "cart-mini", cart_params or None),
|
||||||
|
("account", "auth-menu", auth_params or None),
|
||||||
|
("blog", "nav-tree", nav_params),
|
||||||
|
])
|
||||||
|
ctx["cart_mini_html"] = cart_mini_html
|
||||||
|
ctx["auth_menu_html"] = auth_menu_html
|
||||||
|
ctx["nav_tree_html"] = nav_tree_html
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> "Quart":
|
def create_app() -> "Quart":
|
||||||
app = create_base_app("coop", context_fn=coop_context)
|
from services import register_domain_services
|
||||||
|
|
||||||
|
app = create_base_app(
|
||||||
|
"blog",
|
||||||
|
context_fn=blog_context,
|
||||||
|
domain_services_fn=register_domain_services,
|
||||||
|
)
|
||||||
|
|
||||||
# App-specific templates override shared templates
|
# App-specific templates override shared templates
|
||||||
app_templates = str(Path(__file__).resolve().parent / "templates")
|
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||||
@@ -59,8 +86,6 @@ def create_app() -> "Quart":
|
|||||||
])
|
])
|
||||||
|
|
||||||
# --- blueprints ---
|
# --- blueprints ---
|
||||||
app.register_blueprint(register_auth_bp())
|
|
||||||
|
|
||||||
app.register_blueprint(
|
app.register_blueprint(
|
||||||
register_blog_bp(
|
register_blog_bp(
|
||||||
url_prefix=config()["blog_root"],
|
url_prefix=config()["blog_root"],
|
||||||
@@ -72,9 +97,7 @@ def create_app() -> "Quart":
|
|||||||
app.register_blueprint(register_admin("/settings"))
|
app.register_blueprint(register_admin("/settings"))
|
||||||
app.register_blueprint(register_menu_items())
|
app.register_blueprint(register_menu_items())
|
||||||
app.register_blueprint(register_snippets())
|
app.register_blueprint(register_snippets())
|
||||||
|
app.register_blueprint(register_fragments())
|
||||||
# Internal API (server-to-server, CSRF-exempt)
|
|
||||||
app.register_blueprint(register_coop_api())
|
|
||||||
|
|
||||||
# --- KV admin endpoints ---
|
# --- KV admin endpoints ---
|
||||||
@app.get("/settings/kv/<key>")
|
@app.get("/settings/kv/<key>")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from .auth.routes import register as register_auth_bp
|
|
||||||
from .blog.routes import register as register_blog_bp
|
from .blog.routes import register as register_blog_bp
|
||||||
from .admin.routes import register as register_admin
|
from .admin.routes import register as register_admin
|
||||||
from .menu_items.routes import register as register_menu_items
|
from .menu_items.routes import register as register_menu_items
|
||||||
from .snippets.routes import register as register_snippets
|
from .snippets.routes import register as register_snippets
|
||||||
from .coop_api import register as register_coop_api
|
from .fragments import register_fragments
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ from quart import (
|
|||||||
request,
|
request,
|
||||||
jsonify
|
jsonify
|
||||||
)
|
)
|
||||||
from suma_browser.app.redis_cacher import clear_all_cache
|
from shared.browser.app.redis_cacher import clear_all_cache
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from config import config
|
from shared.config import config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
def register(url_prefix):
|
def register(url_prefix):
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from quart import (
|
|
||||||
Blueprint,
|
|
||||||
request,
|
|
||||||
render_template,
|
|
||||||
make_response,
|
|
||||||
redirect,
|
|
||||||
url_for,
|
|
||||||
session as qsession,
|
|
||||||
g,
|
|
||||||
current_app,
|
|
||||||
)
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from ..blog.ghost.ghost_sync import (
|
|
||||||
sync_member_to_ghost,
|
|
||||||
)
|
|
||||||
|
|
||||||
from db.session import get_session
|
|
||||||
from models import User, MagicLink, UserNewsletter
|
|
||||||
from models.ghost_membership_entities import GhostNewsletter
|
|
||||||
from config import config
|
|
||||||
from utils import host_url
|
|
||||||
from shared.urls import coop_url
|
|
||||||
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
|
||||||
from shared.cart_identity import current_cart_identity
|
|
||||||
from shared.internal_api import post as api_post
|
|
||||||
from .services import pop_login_redirect_target, store_login_redirect_target
|
|
||||||
from .services.auth_operations import (
|
|
||||||
get_app_host,
|
|
||||||
get_app_root,
|
|
||||||
send_magic_email,
|
|
||||||
load_user_by_id,
|
|
||||||
find_or_create_user,
|
|
||||||
create_magic_link,
|
|
||||||
validate_magic_link,
|
|
||||||
validate_email,
|
|
||||||
)
|
|
||||||
|
|
||||||
oob = {
|
|
||||||
"oob_extends": "oob_elements.html",
|
|
||||||
"extends": "_types/root/_index.html",
|
|
||||||
"parent_id": "root-header-child",
|
|
||||||
"child_id": "auth-header-child",
|
|
||||||
"header": "_types/auth/header/_header.html",
|
|
||||||
"parent_header": "_types/root/header/_header.html",
|
|
||||||
"nav": "_types/auth/_nav.html",
|
|
||||||
"main": "_types/auth/_main_panel.html"
|
|
||||||
}
|
|
||||||
def register(url_prefix="/auth"):
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
|
|
||||||
|
|
||||||
@auth_bp.before_request
|
|
||||||
def route():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
SESSION_USER_KEY = "uid"
|
|
||||||
@auth_bp.context_processor
|
|
||||||
def context():
|
|
||||||
return {
|
|
||||||
"oob": oob,
|
|
||||||
}
|
|
||||||
|
|
||||||
# NOTE: load_current_user moved to shared/user_loader.py
|
|
||||||
# and registered in shared/factory.py as an app-level before_request
|
|
||||||
|
|
||||||
@auth_bp.get("/login/")
|
|
||||||
async def login_form():
|
|
||||||
store_login_redirect_target()
|
|
||||||
if g.get("user"):
|
|
||||||
return redirect(coop_url("/"))
|
|
||||||
return await render_template("_types/auth/login.html")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.get("/account/")
|
|
||||||
async def account():
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
|
||||||
|
|
||||||
if not g.get("user"):
|
|
||||||
return redirect(host_url(url_for("auth.login_form")))
|
|
||||||
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
|
|
||||||
# For now, render full template for both HTMX and normal requests
|
|
||||||
# Determine which template to use based on request type
|
|
||||||
if not is_htmx_request():
|
|
||||||
# Normal browser request: full page with layout
|
|
||||||
html = await render_template("_types/auth/index.html")
|
|
||||||
else:
|
|
||||||
# HTMX request: main panel + OOB elements
|
|
||||||
html = await render_template(
|
|
||||||
"_types/auth/_oob_elements.html",
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
@auth_bp.get("/newsletters/")
|
|
||||||
async def newsletters():
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
|
||||||
|
|
||||||
if not g.get("user"):
|
|
||||||
return redirect(host_url(url_for("auth.login_form")))
|
|
||||||
|
|
||||||
# Fetch all newsletters, sorted alphabetically
|
|
||||||
result = await g.s.execute(
|
|
||||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
|
||||||
)
|
|
||||||
all_newsletters = result.scalars().all()
|
|
||||||
|
|
||||||
# Fetch user's subscription states
|
|
||||||
sub_result = await g.s.execute(
|
|
||||||
select(UserNewsletter).where(
|
|
||||||
UserNewsletter.user_id == g.user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
|
||||||
|
|
||||||
# Build list with subscription state for template
|
|
||||||
newsletter_list = []
|
|
||||||
for nl in all_newsletters:
|
|
||||||
un = user_subs.get(nl.id)
|
|
||||||
newsletter_list.append({
|
|
||||||
"newsletter": nl,
|
|
||||||
"un": un,
|
|
||||||
"subscribed": un.subscribed if un else False,
|
|
||||||
})
|
|
||||||
|
|
||||||
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
|
|
||||||
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_template(
|
|
||||||
"_types/auth/index.html",
|
|
||||||
oob=nl_oob,
|
|
||||||
newsletter_list=newsletter_list,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
html = await render_template(
|
|
||||||
"_types/auth/_oob_elements.html",
|
|
||||||
oob=nl_oob,
|
|
||||||
newsletter_list=newsletter_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
@auth_bp.post("/start/")
|
|
||||||
@clear_cache(tag_scope="user", clear_user=True)
|
|
||||||
async def start_login():
|
|
||||||
# 1. Get and validate email
|
|
||||||
form = await request.form
|
|
||||||
email_input = form.get("email") or ""
|
|
||||||
|
|
||||||
is_valid, email = validate_email(email_input)
|
|
||||||
if not is_valid:
|
|
||||||
return (
|
|
||||||
await render_template(
|
|
||||||
"_types/auth/login.html",
|
|
||||||
error="Please enter a valid email address.",
|
|
||||||
email=email_input,
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Create/find user and issue magic link token
|
|
||||||
user = await find_or_create_user(g.s, email)
|
|
||||||
token, expires = await create_magic_link(g.s, user.id)
|
|
||||||
g.s.commit()
|
|
||||||
|
|
||||||
# 3. Build the magic link URL
|
|
||||||
magic_url = host_url(url_for("auth.magic", token=token))
|
|
||||||
|
|
||||||
# 4. Try sending the email
|
|
||||||
email_error = None
|
|
||||||
try:
|
|
||||||
await send_magic_email(email, magic_url)
|
|
||||||
except Exception as e:
|
|
||||||
print("EMAIL SEND FAILED:", repr(e))
|
|
||||||
email_error = (
|
|
||||||
"We couldn't send the email automatically. "
|
|
||||||
"Please try again in a moment."
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Render "check your email" page
|
|
||||||
return await render_template(
|
|
||||||
"_types/auth/check_email.html",
|
|
||||||
email=email,
|
|
||||||
email_error=email_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
@auth_bp.get("/magic/<token>/")
|
|
||||||
async def magic(token: str):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
user_id: int | None = None
|
|
||||||
|
|
||||||
# ---- Step 1: Validate & consume magic link ----
|
|
||||||
try:
|
|
||||||
async with get_session() as s:
|
|
||||||
async with s.begin():
|
|
||||||
user, error = await validate_magic_link(s, token)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
return (
|
|
||||||
await render_template(
|
|
||||||
"_types/auth/login.html",
|
|
||||||
error=error,
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id = user.id
|
|
||||||
|
|
||||||
# Try to ensure Ghost membership inside this txn
|
|
||||||
try:
|
|
||||||
if not user.ghost_id:
|
|
||||||
await sync_member_to_ghost(s, user.id)
|
|
||||||
except Exception:
|
|
||||||
current_app.logger.exception(
|
|
||||||
"[auth] Ghost upsert failed for user_id=%s", user.id
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# Any DB/Ghost error → generic failure
|
|
||||||
return (
|
|
||||||
await render_template(
|
|
||||||
"_types/auth/login.html",
|
|
||||||
error="Could not sign you in right now. Please try again.",
|
|
||||||
),
|
|
||||||
502,
|
|
||||||
)
|
|
||||||
|
|
||||||
# At this point:
|
|
||||||
# - magic link is consumed
|
|
||||||
# - user_id is valid
|
|
||||||
# - Ghost membership is ensured or we already returned 502
|
|
||||||
|
|
||||||
assert user_id is not None # for type checkers / sanity
|
|
||||||
|
|
||||||
# Figure out any anonymous session we want to adopt
|
|
||||||
ident = current_cart_identity()
|
|
||||||
anon_session_id = ident.get("session_id")
|
|
||||||
|
|
||||||
# ---- Step 3: best-effort local update (non-fatal) ----
|
|
||||||
try:
|
|
||||||
async with get_session() as s:
|
|
||||||
async with s.begin():
|
|
||||||
u2 = await s.get(User, user_id)
|
|
||||||
if u2:
|
|
||||||
u2.last_login_at = now
|
|
||||||
# s.begin() will commit on successful exit
|
|
||||||
except SQLAlchemyError:
|
|
||||||
current_app.logger.exception(
|
|
||||||
"[auth] non-fatal DB update after Ghost upsert for user_id=%s", user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Adopt cart + calendar entries via cart app internal API
|
|
||||||
if anon_session_id:
|
|
||||||
await api_post(
|
|
||||||
"cart",
|
|
||||||
"/internal/cart/adopt",
|
|
||||||
json={"user_id": user_id, "session_id": anon_session_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Finalize login ----
|
|
||||||
qsession[SESSION_USER_KEY] = user_id
|
|
||||||
|
|
||||||
# Redirect back to where they came from, if we stored it.
|
|
||||||
redirect_url = pop_login_redirect_target()
|
|
||||||
return redirect(redirect_url, 303)
|
|
||||||
|
|
||||||
@auth_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
|
||||||
async def toggle_newsletter(newsletter_id: int):
|
|
||||||
if not g.get("user"):
|
|
||||||
return "", 401
|
|
||||||
|
|
||||||
result = await g.s.execute(
|
|
||||||
select(UserNewsletter).where(
|
|
||||||
UserNewsletter.user_id == g.user.id,
|
|
||||||
UserNewsletter.newsletter_id == newsletter_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
un = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if un:
|
|
||||||
un.subscribed = not un.subscribed
|
|
||||||
else:
|
|
||||||
un = UserNewsletter(
|
|
||||||
user_id=g.user.id,
|
|
||||||
newsletter_id=newsletter_id,
|
|
||||||
subscribed=True,
|
|
||||||
)
|
|
||||||
g.s.add(un)
|
|
||||||
|
|
||||||
await g.s.flush()
|
|
||||||
|
|
||||||
return await render_template(
|
|
||||||
"_types/auth/_newsletter_toggle.html",
|
|
||||||
un=un,
|
|
||||||
)
|
|
||||||
|
|
||||||
@auth_bp.post("/logout/")
|
|
||||||
async def logout():
|
|
||||||
qsession.pop(SESSION_USER_KEY, None)
|
|
||||||
return redirect(coop_url("/"))
|
|
||||||
|
|
||||||
return auth_bp
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from .login_redirect import pop_login_redirect_target, store_login_redirect_target
|
|
||||||
from .auth_operations import (
|
|
||||||
get_app_host,
|
|
||||||
get_app_root,
|
|
||||||
send_magic_email,
|
|
||||||
load_user_by_id,
|
|
||||||
find_or_create_user,
|
|
||||||
create_magic_link,
|
|
||||||
validate_magic_link,
|
|
||||||
validate_email,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"pop_login_redirect_target",
|
|
||||||
"store_login_redirect_target",
|
|
||||||
"get_app_host",
|
|
||||||
"get_app_root",
|
|
||||||
"send_magic_email",
|
|
||||||
"load_user_by_id",
|
|
||||||
"find_or_create_user",
|
|
||||||
"create_magic_link",
|
|
||||||
"validate_magic_link",
|
|
||||||
"validate_email",
|
|
||||||
]
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
from quart import current_app, request, g
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from models import User, MagicLink, UserNewsletter
|
|
||||||
from config import config
|
|
||||||
|
|
||||||
|
|
||||||
def get_app_host() -> str:
|
|
||||||
"""Get the application host URL from config or environment."""
|
|
||||||
host = (
|
|
||||||
config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000"
|
|
||||||
).rstrip("/")
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def get_app_root() -> str:
|
|
||||||
"""Get the application root path from request context."""
|
|
||||||
root = (g.root).rstrip("/")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
async def send_magic_email(to_email: str, link_url: str) -> None:
|
|
||||||
"""
|
|
||||||
Send magic link email via SMTP if configured, otherwise log to console.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
to_email: Recipient email address
|
|
||||||
link_url: Magic link URL to include in email
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If SMTP sending fails
|
|
||||||
"""
|
|
||||||
host = os.getenv("SMTP_HOST")
|
|
||||||
port = int(os.getenv("SMTP_PORT") or "587")
|
|
||||||
username = os.getenv("SMTP_USER")
|
|
||||||
password = os.getenv("SMTP_PASS")
|
|
||||||
mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com"
|
|
||||||
|
|
||||||
subject = "Your sign-in link"
|
|
||||||
body = f"""Hello,
|
|
||||||
|
|
||||||
Click this link to sign in:
|
|
||||||
{link_url}
|
|
||||||
|
|
||||||
This link will expire in 15 minutes.
|
|
||||||
|
|
||||||
If you did not request this, you can ignore this email.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not host or not username or not password:
|
|
||||||
# Fallback: log to console
|
|
||||||
current_app.logger.warning(
|
|
||||||
"SMTP not configured. Printing magic link to console for %s: %s",
|
|
||||||
to_email,
|
|
||||||
link_url,
|
|
||||||
)
|
|
||||||
print(f"[DEV] Magic link for {to_email}: {link_url}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Lazy import to avoid dependency unless used
|
|
||||||
import aiosmtplib
|
|
||||||
from email.message import EmailMessage
|
|
||||||
|
|
||||||
msg = EmailMessage()
|
|
||||||
msg["From"] = mail_from
|
|
||||||
msg["To"] = to_email
|
|
||||||
msg["Subject"] = subject
|
|
||||||
msg.set_content(body)
|
|
||||||
|
|
||||||
is_secure = port == 465 # implicit TLS if true
|
|
||||||
if is_secure:
|
|
||||||
# implicit TLS (like nodemailer secure: true)
|
|
||||||
smtp = aiosmtplib.SMTP(
|
|
||||||
hostname=host,
|
|
||||||
port=port,
|
|
||||||
use_tls=True,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# plain connect then STARTTLS (like secure: false but with TLS upgrade)
|
|
||||||
smtp = aiosmtplib.SMTP(
|
|
||||||
hostname=host,
|
|
||||||
port=port,
|
|
||||||
start_tls=True,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with smtp:
|
|
||||||
await smtp.send_message(msg)
|
|
||||||
|
|
||||||
|
|
||||||
async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
|
||||||
"""
|
|
||||||
Load a user by ID with labels and newsletters eagerly loaded.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Database session
|
|
||||||
user_id: User ID to load
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User object or None if not found
|
|
||||||
"""
|
|
||||||
stmt = (
|
|
||||||
select(User)
|
|
||||||
.options(
|
|
||||||
selectinload(User.labels),
|
|
||||||
selectinload(User.user_newsletters).selectinload(
|
|
||||||
UserNewsletter.newsletter
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(User.id == user_id)
|
|
||||||
)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def find_or_create_user(session: AsyncSession, email: str) -> User:
|
|
||||||
"""
|
|
||||||
Find existing user by email or create a new one.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Database session
|
|
||||||
email: User email address (should be lowercase and trimmed)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User object (either existing or newly created)
|
|
||||||
"""
|
|
||||||
result = await session.execute(select(User).where(User.email == email))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
user = User(email=email)
|
|
||||||
session.add(user)
|
|
||||||
await session.flush() # Ensure user.id exists
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def create_magic_link(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
purpose: str = "signin",
|
|
||||||
expires_minutes: int = 15,
|
|
||||||
) -> Tuple[str, datetime]:
|
|
||||||
"""
|
|
||||||
Create a new magic link token for authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Database session
|
|
||||||
user_id: User ID to create link for
|
|
||||||
purpose: Purpose of the link (default: "signin")
|
|
||||||
expires_minutes: Minutes until expiration (default: 15)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (token, expires_at)
|
|
||||||
"""
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
|
|
||||||
|
|
||||||
ml = MagicLink(
|
|
||||||
token=token,
|
|
||||||
user_id=user_id,
|
|
||||||
purpose=purpose,
|
|
||||||
expires_at=expires,
|
|
||||||
ip=request.headers.get("x-forwarded-for", request.remote_addr),
|
|
||||||
user_agent=request.headers.get("user-agent"),
|
|
||||||
)
|
|
||||||
session.add(ml)
|
|
||||||
|
|
||||||
return token, expires
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_magic_link(
|
|
||||||
session: AsyncSession,
|
|
||||||
token: str,
|
|
||||||
) -> Tuple[Optional[User], Optional[str]]:
|
|
||||||
"""
|
|
||||||
Validate and consume a magic link token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Database session (should be in a transaction)
|
|
||||||
token: Magic link token to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (user, error_message)
|
|
||||||
- If user is None, error_message contains the reason
|
|
||||||
- If user is returned, the link was valid and has been consumed
|
|
||||||
"""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
ml = await session.scalar(
|
|
||||||
select(MagicLink)
|
|
||||||
.where(MagicLink.token == token)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ml or ml.purpose != "signin":
|
|
||||||
return None, "Invalid or expired link."
|
|
||||||
|
|
||||||
if ml.used_at or ml.expires_at < now:
|
|
||||||
return None, "This link has expired. Please request a new one."
|
|
||||||
|
|
||||||
user = await session.get(User, ml.user_id)
|
|
||||||
if not user:
|
|
||||||
return None, "User not found."
|
|
||||||
|
|
||||||
# Mark link as used
|
|
||||||
ml.used_at = now
|
|
||||||
|
|
||||||
return user, None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_email(email: str) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Validate email address format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
email: Email address to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_valid, normalized_email)
|
|
||||||
"""
|
|
||||||
email = email.strip().lower()
|
|
||||||
|
|
||||||
if not email or "@" not in email:
|
|
||||||
return False, email
|
|
||||||
|
|
||||||
return True, email
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from urllib.parse import urlparse
|
|
||||||
from quart import session
|
|
||||||
|
|
||||||
from shared.urls import coop_url
|
|
||||||
|
|
||||||
|
|
||||||
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
|
|
||||||
|
|
||||||
|
|
||||||
def store_login_redirect_target() -> None:
|
|
||||||
from quart import request
|
|
||||||
|
|
||||||
target = request.args.get("next")
|
|
||||||
if not target:
|
|
||||||
ref = request.referrer or ""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(ref)
|
|
||||||
target = parsed.path or ""
|
|
||||||
except Exception:
|
|
||||||
target = ""
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Accept both relative paths and absolute URLs (cross-app redirects)
|
|
||||||
if target.startswith("http://") or target.startswith("https://"):
|
|
||||||
session[LOGIN_REDIRECT_SESSION_KEY] = target
|
|
||||||
elif target.startswith("/") and not target.startswith("//"):
|
|
||||||
session[LOGIN_REDIRECT_SESSION_KEY] = target
|
|
||||||
|
|
||||||
|
|
||||||
def pop_login_redirect_target() -> str:
|
|
||||||
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
|
|
||||||
if not path or not isinstance(path, str):
|
|
||||||
return coop_url("/auth/")
|
|
||||||
|
|
||||||
# Absolute URL: return as-is (cross-app redirect)
|
|
||||||
if path.startswith("http://") or path.startswith("https://"):
|
|
||||||
return path
|
|
||||||
|
|
||||||
# Relative path: must start with / and not //
|
|
||||||
if path.startswith("/") and not path.startswith("//"):
|
|
||||||
return coop_url(path)
|
|
||||||
|
|
||||||
return coop_url("/auth/")
|
|
||||||
@@ -12,9 +12,9 @@ from quart import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from suma_browser.app.redis_cacher import invalidate_tag_cache
|
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||||
|
|
||||||
from models.tag_group import TagGroup, TagGroupTag
|
from models.tag_group import TagGroup, TagGroupTag
|
||||||
from models.ghost_content import Tag
|
from models.ghost_content import Tag
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ from quart import request
|
|||||||
|
|
||||||
from typing import Iterable, Optional, Union
|
from typing import Iterable, Optional, Union
|
||||||
|
|
||||||
from suma_browser.app.filters.qs_base import (
|
from shared.browser.app.filters.qs_base import (
|
||||||
KEEP, _norm, make_filter_set, build_qs,
|
KEEP, _norm, make_filter_set, build_qs,
|
||||||
)
|
)
|
||||||
from suma_browser.app.filters.query_types import BlogQuery
|
from shared.browser.app.filters.query_types import BlogQuery
|
||||||
|
|
||||||
|
|
||||||
def decode() -> BlogQuery:
|
def decode() -> BlogQuery:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import httpx
|
|||||||
from quart import Blueprint, request, jsonify, g
|
from quart import Blueprint, request, jsonify, g
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin, require_login
|
from shared.browser.app.authz import require_admin, require_login
|
||||||
from models import Snippet
|
from models import Snippet
|
||||||
from .ghost_admin_token import make_ghost_admin_jwt
|
from .ghost_admin_token import make_ghost_admin_jwt
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ def _check(resp: httpx.Response) -> None:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
async def get_post_for_edit(ghost_id: str) -> dict | None:
|
async def get_post_for_edit(ghost_id: str, *, is_page: bool = False) -> dict | None:
|
||||||
"""Fetch a single post by Ghost ID, including lexical source."""
|
"""Fetch a single post/page by Ghost ID, including lexical source."""
|
||||||
|
resource = "pages" if is_page else "posts"
|
||||||
url = (
|
url = (
|
||||||
f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/"
|
f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
|
||||||
"?formats=lexical,html,mobiledoc&include=newsletters"
|
"?formats=lexical,html,mobiledoc&include=newsletters"
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
@@ -41,7 +42,7 @@ async def get_post_for_edit(ghost_id: str) -> dict | None:
|
|||||||
if resp.status_code == 404:
|
if resp.status_code == 404:
|
||||||
return None
|
return None
|
||||||
_check(resp)
|
_check(resp)
|
||||||
return resp.json()["posts"][0]
|
return resp.json()[resource][0]
|
||||||
|
|
||||||
|
|
||||||
async def create_post(
|
async def create_post(
|
||||||
@@ -114,6 +115,7 @@ async def update_post(
|
|||||||
newsletter_slug: str | None = None,
|
newsletter_slug: str | None = None,
|
||||||
email_segment: str | None = None,
|
email_segment: str | None = None,
|
||||||
email_only: bool | None = None,
|
email_only: bool | None = None,
|
||||||
|
is_page: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update an existing Ghost post. Returns the updated post dict.
|
"""Update an existing Ghost post. Returns the updated post dict.
|
||||||
|
|
||||||
@@ -141,9 +143,10 @@ async def update_post(
|
|||||||
post_body["status"] = status
|
post_body["status"] = status
|
||||||
if email_only:
|
if email_only:
|
||||||
post_body["email_only"] = True
|
post_body["email_only"] = True
|
||||||
payload = {"posts": [post_body]}
|
resource = "pages" if is_page else "posts"
|
||||||
|
payload = {resource: [post_body]}
|
||||||
|
|
||||||
url = f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/"
|
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
|
||||||
if newsletter_slug:
|
if newsletter_slug:
|
||||||
url += f"?newsletter={newsletter_slug}"
|
url += f"?newsletter={newsletter_slug}"
|
||||||
if email_segment:
|
if email_segment:
|
||||||
@@ -151,7 +154,7 @@ async def update_post(
|
|||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
resp = await client.put(url, json=payload, headers=_auth_header())
|
resp = await client.put(url, json=payload, headers=_auth_header())
|
||||||
_check(resp)
|
_check(resp)
|
||||||
return resp.json()["posts"][0]
|
return resp.json()[resource][0]
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_FIELDS = (
|
_SETTINGS_FIELDS = (
|
||||||
@@ -178,22 +181,24 @@ _SETTINGS_FIELDS = (
|
|||||||
async def update_post_settings(
|
async def update_post_settings(
|
||||||
ghost_id: str,
|
ghost_id: str,
|
||||||
updated_at: str,
|
updated_at: str,
|
||||||
|
is_page: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update Ghost post settings (slug, tags, SEO, social, etc.).
|
"""Update Ghost post/page settings (slug, tags, SEO, social, etc.).
|
||||||
|
|
||||||
Only non-None keyword args are included in the PUT payload.
|
Only non-None keyword args are included in the PUT payload.
|
||||||
Accepts any key from ``_SETTINGS_FIELDS``.
|
Accepts any key from ``_SETTINGS_FIELDS``.
|
||||||
"""
|
"""
|
||||||
|
resource = "pages" if is_page else "posts"
|
||||||
post_body: dict = {"updated_at": updated_at}
|
post_body: dict = {"updated_at": updated_at}
|
||||||
for key in _SETTINGS_FIELDS:
|
for key in _SETTINGS_FIELDS:
|
||||||
val = kwargs.get(key)
|
val = kwargs.get(key)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
post_body[key] = val
|
post_body[key] = val
|
||||||
|
|
||||||
payload = {"posts": [post_body]}
|
payload = {resource: [post_body]}
|
||||||
url = f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/"
|
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
resp = await client.put(url, json=payload, headers=_auth_header())
|
resp = await client.put(url, json=payload, headers=_auth_header())
|
||||||
_check(resp)
|
_check(resp)
|
||||||
return resp.json()["posts"][0]
|
return resp.json()[resource][0]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from html import escape as html_escape
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,11 +15,11 @@ from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON colu
|
|||||||
from models.ghost_content import (
|
from models.ghost_content import (
|
||||||
Post, Author, Tag, PostAuthor, PostTag
|
Post, Author, Tag, PostAuthor, PostTag
|
||||||
)
|
)
|
||||||
from models.page_config import PageConfig
|
from shared.models.page_config import PageConfig
|
||||||
|
|
||||||
# User-centric membership models
|
# User-centric membership models
|
||||||
from models import User
|
from shared.models import User
|
||||||
from models.ghost_membership_entities import (
|
from shared.models.ghost_membership_entities import (
|
||||||
GhostLabel, UserLabel,
|
GhostLabel, UserLabel,
|
||||||
GhostNewsletter, UserNewsletter,
|
GhostNewsletter, UserNewsletter,
|
||||||
GhostTier, GhostSubscription,
|
GhostTier, GhostSubscription,
|
||||||
@@ -29,7 +31,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
|
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
|
||||||
|
|
||||||
from suma_browser.app.utils import (
|
from shared.browser.app.utils import (
|
||||||
utcnow
|
utcnow
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,13 +166,8 @@ async def _upsert_tag(sess: AsyncSession, gt: Dict[str, Any]) -> Tag:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> Post:
|
def _apply_ghost_fields(obj: Post, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> None:
|
||||||
res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"]))
|
"""Apply Ghost API fields to a Post ORM object."""
|
||||||
obj = res.scalar_one_or_none()
|
|
||||||
if obj is None:
|
|
||||||
obj = Post(ghost_id=gp["id"]) # type: ignore[call-arg]
|
|
||||||
sess.add(obj)
|
|
||||||
|
|
||||||
obj.deleted_at = None # revive if soft-deleted
|
obj.deleted_at = None # revive if soft-deleted
|
||||||
|
|
||||||
obj.uuid = gp.get("uuid") or obj.uuid
|
obj.uuid = gp.get("uuid") or obj.uuid
|
||||||
@@ -213,6 +210,34 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
|
|||||||
pt = gp.get("primary_tag")
|
pt = gp.get("primary_tag")
|
||||||
obj.primary_tag_id = tag_map[pt["id"].strip()].id if (pt and pt["id"] in tag_map) else None # type: ignore[index]
|
obj.primary_tag_id = tag_map[pt["id"].strip()].id if (pt and pt["id"] in tag_map) else None # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> tuple[Post, str | None]:
|
||||||
|
"""Upsert a post. Returns (post, old_status) where old_status is None for new rows."""
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"]))
|
||||||
|
obj = res.scalar_one_or_none()
|
||||||
|
|
||||||
|
old_status = obj.status if obj is not None else None
|
||||||
|
|
||||||
|
if obj is not None:
|
||||||
|
# Row exists — just update
|
||||||
|
_apply_ghost_fields(obj, gp, author_map, tag_map)
|
||||||
|
await sess.flush()
|
||||||
|
else:
|
||||||
|
# Row doesn't exist — try to insert within a savepoint
|
||||||
|
obj = Post(ghost_id=gp["id"]) # type: ignore[call-arg]
|
||||||
|
try:
|
||||||
|
async with sess.begin_nested():
|
||||||
|
sess.add(obj)
|
||||||
|
_apply_ghost_fields(obj, gp, author_map, tag_map)
|
||||||
|
await sess.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
# Race condition: another request inserted this ghost_id.
|
||||||
|
# Savepoint rolled back; re-select and update.
|
||||||
|
res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"]))
|
||||||
|
obj = res.scalar_one()
|
||||||
|
_apply_ghost_fields(obj, gp, author_map, tag_map)
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
|
|
||||||
# Backfill user_id from primary author email if not already set
|
# Backfill user_id from primary author email if not already set
|
||||||
@@ -242,13 +267,13 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[
|
|||||||
# Auto-create PageConfig for pages
|
# Auto-create PageConfig for pages
|
||||||
if obj.is_page:
|
if obj.is_page:
|
||||||
existing_pc = (await sess.execute(
|
existing_pc = (await sess.execute(
|
||||||
select(PageConfig).where(PageConfig.post_id == obj.id)
|
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == obj.id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing_pc is None:
|
if existing_pc is None:
|
||||||
sess.add(PageConfig(post_id=obj.id, features={}))
|
sess.add(PageConfig(container_type="page", container_id=obj.id, features={}))
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
|
|
||||||
return obj
|
return obj, old_status
|
||||||
|
|
||||||
async def _ghost_find_member_by_email(email: str) -> Optional[dict]:
|
async def _ghost_find_member_by_email(email: str) -> Optional[dict]:
|
||||||
"""Return first Ghost member with this email, or None."""
|
"""Return first Ghost member with this email, or None."""
|
||||||
@@ -970,6 +995,77 @@ async def fetch_single_tag_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]
|
|||||||
return tags[0] if tags else None
|
return tags[0] if tags else None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ap_post_data(post, post_url: str, tag_objs: list) -> dict:
|
||||||
|
"""Build rich AP object_data for a blog post/page.
|
||||||
|
|
||||||
|
Produces a Note with HTML content (excerpt), feature image + inline
|
||||||
|
images as attachments, and tags as AP Hashtag objects.
|
||||||
|
"""
|
||||||
|
# Content HTML: title + excerpt + "Read more" link
|
||||||
|
parts: list[str] = []
|
||||||
|
if post.title:
|
||||||
|
parts.append(f"<p><strong>{html_escape(post.title)}</strong></p>")
|
||||||
|
|
||||||
|
body = post.plaintext or post.custom_excerpt or post.excerpt or ""
|
||||||
|
|
||||||
|
if body:
|
||||||
|
for para in body.split("\n\n"):
|
||||||
|
para = para.strip()
|
||||||
|
if para:
|
||||||
|
parts.append(f"<p>{html_escape(para)}</p>")
|
||||||
|
|
||||||
|
parts.append(f'<p><a href="{html_escape(post_url)}">Read more \u2192</a></p>')
|
||||||
|
|
||||||
|
# Hashtag links in content (Mastodon expects them inline too)
|
||||||
|
if tag_objs:
|
||||||
|
ht_links = []
|
||||||
|
for t in tag_objs:
|
||||||
|
clean = t.slug.replace("-", "")
|
||||||
|
ht_links.append(
|
||||||
|
f'<a href="{html_escape(post_url)}tag/{t.slug}/" rel="tag">#{clean}</a>'
|
||||||
|
)
|
||||||
|
parts.append(f'<p>{" ".join(ht_links)}</p>')
|
||||||
|
|
||||||
|
obj: dict = {
|
||||||
|
"name": post.title or "",
|
||||||
|
"content": "\n".join(parts),
|
||||||
|
"url": post_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attachments: feature image + inline images (max 4)
|
||||||
|
attachments: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
if post.feature_image:
|
||||||
|
att: dict = {"type": "Image", "url": post.feature_image}
|
||||||
|
if post.feature_image_alt:
|
||||||
|
att["name"] = post.feature_image_alt
|
||||||
|
attachments.append(att)
|
||||||
|
seen.add(post.feature_image)
|
||||||
|
|
||||||
|
if post.html:
|
||||||
|
for src in re.findall(r'<img[^>]+src="([^"]+)"', post.html):
|
||||||
|
if src not in seen and len(attachments) < 4:
|
||||||
|
attachments.append({"type": "Image", "url": src})
|
||||||
|
seen.add(src)
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
obj["attachment"] = attachments
|
||||||
|
|
||||||
|
# AP Hashtag objects
|
||||||
|
if tag_objs:
|
||||||
|
obj["tag"] = [
|
||||||
|
{
|
||||||
|
"type": "Hashtag",
|
||||||
|
"href": f"{post_url}tag/{t.slug}/",
|
||||||
|
"name": f"#{t.slug.replace('-', '')}",
|
||||||
|
}
|
||||||
|
for t in tag_objs
|
||||||
|
]
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
async def sync_single_post(sess: AsyncSession, ghost_id: str) -> None:
|
async def sync_single_post(sess: AsyncSession, ghost_id: str) -> None:
|
||||||
gp = await fetch_single_post_from_ghost(ghost_id)
|
gp = await fetch_single_post_from_ghost(ghost_id)
|
||||||
if gp is None:
|
if gp is None:
|
||||||
@@ -998,12 +1094,45 @@ async def sync_single_post(sess: AsyncSession, ghost_id: str) -> None:
|
|||||||
tag_obj = await _upsert_tag(sess, pt)
|
tag_obj = await _upsert_tag(sess, pt)
|
||||||
tag_map[pt["id"]] = tag_obj
|
tag_map[pt["id"]] = tag_obj
|
||||||
|
|
||||||
await _upsert_post(sess, gp, author_map, tag_map)
|
post, old_status = await _upsert_post(sess, gp, author_map, tag_map)
|
||||||
# auto-commit
|
|
||||||
|
# Publish to federation inline (posts, not pages)
|
||||||
|
if not post.is_page and post.user_id:
|
||||||
|
from shared.services.federation_publish import try_publish
|
||||||
|
from shared.infrastructure.urls import app_url
|
||||||
|
post_url = app_url("blog", f"/{post.slug}/")
|
||||||
|
post_tags = [tag_map[t["id"]] for t in (gp.get("tags") or []) if t["id"] in tag_map]
|
||||||
|
|
||||||
|
if post.status == "published":
|
||||||
|
activity_type = "Create" if old_status != "published" else "Update"
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=post.user_id,
|
||||||
|
activity_type=activity_type,
|
||||||
|
object_type="Note",
|
||||||
|
object_data=_build_ap_post_data(post, post_url, post_tags),
|
||||||
|
source_type="Post",
|
||||||
|
source_id=post.id,
|
||||||
|
)
|
||||||
|
elif old_status == "published" and post.status != "published":
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=post.user_id,
|
||||||
|
activity_type="Delete",
|
||||||
|
object_type="Tombstone",
|
||||||
|
object_data={
|
||||||
|
"id": post_url,
|
||||||
|
"formerType": "Note",
|
||||||
|
},
|
||||||
|
source_type="Post",
|
||||||
|
source_id=post.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def sync_single_page(sess: AsyncSession, ghost_id: str) -> None:
|
async def sync_single_page(sess: AsyncSession, ghost_id: str) -> None:
|
||||||
gp = await fetch_single_page_from_ghost(ghost_id)
|
gp = await fetch_single_page_from_ghost(ghost_id)
|
||||||
|
if gp is not None:
|
||||||
|
gp["page"] = True # Ghost /pages/ endpoint may omit this flag
|
||||||
if gp is None:
|
if gp is None:
|
||||||
res = await sess.execute(select(Post).where(Post.ghost_id == ghost_id))
|
res = await sess.execute(select(Post).where(Post.ghost_id == ghost_id))
|
||||||
obj = res.scalar_one_or_none()
|
obj = res.scalar_one_or_none()
|
||||||
@@ -1030,7 +1159,39 @@ async def sync_single_page(sess: AsyncSession, ghost_id: str) -> None:
|
|||||||
tag_obj = await _upsert_tag(sess, pt)
|
tag_obj = await _upsert_tag(sess, pt)
|
||||||
tag_map[pt["id"]] = tag_obj
|
tag_map[pt["id"]] = tag_obj
|
||||||
|
|
||||||
await _upsert_post(sess, gp, author_map, tag_map)
|
post, old_status = await _upsert_post(sess, gp, author_map, tag_map)
|
||||||
|
|
||||||
|
# Publish to federation inline (pages)
|
||||||
|
if post.user_id:
|
||||||
|
from shared.services.federation_publish import try_publish
|
||||||
|
from shared.infrastructure.urls import app_url
|
||||||
|
post_url = app_url("blog", f"/{post.slug}/")
|
||||||
|
post_tags = [tag_map[t["id"]] for t in (gp.get("tags") or []) if t["id"] in tag_map]
|
||||||
|
|
||||||
|
if post.status == "published":
|
||||||
|
activity_type = "Create" if old_status != "published" else "Update"
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=post.user_id,
|
||||||
|
activity_type=activity_type,
|
||||||
|
object_type="Note",
|
||||||
|
object_data=_build_ap_post_data(post, post_url, post_tags),
|
||||||
|
source_type="Post",
|
||||||
|
source_id=post.id,
|
||||||
|
)
|
||||||
|
elif old_status == "published" and post.status != "published":
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=post.user_id,
|
||||||
|
activity_type="Delete",
|
||||||
|
object_type="Tombstone",
|
||||||
|
object_data={
|
||||||
|
"id": post_url,
|
||||||
|
"formerType": "Note",
|
||||||
|
},
|
||||||
|
source_type="Post",
|
||||||
|
source_id=post.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def sync_single_author(sess: AsyncSession, ghost_id: str) -> None:
|
async def sync_single_author(sess: AsyncSession, ghost_id: str) -> None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload, joinedload
|
from sqlalchemy.orm import selectinload, joinedload
|
||||||
|
|
||||||
from models.ghost_content import Post, Author, Tag, PostTag
|
from models.ghost_content import Post, Author, Tag, PostTag
|
||||||
from models.page_config import PageConfig
|
from shared.models.page_config import PageConfig
|
||||||
from models.tag_group import TagGroup, TagGroupTag
|
from models.tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from .ghost_db import DBClient # adjust import path
|
from .ghost_db import DBClient # adjust import path
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
from .filters.qs import makeqs_factory, decode
|
from .filters.qs import makeqs_factory, decode
|
||||||
from .services.posts_data import posts_data
|
from .services.posts_data import posts_data
|
||||||
from .services.pages_data import pages_data
|
from .services.pages_data import pages_data
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
def register(url_prefix, title):
|
def register(url_prefix, title):
|
||||||
blogs_bp = Blueprint("blog", __name__, url_prefix)
|
blogs_bp = Blueprint("blog", __name__, url_prefix)
|
||||||
@@ -80,6 +80,67 @@ def register(url_prefix, title):
|
|||||||
|
|
||||||
@blogs_bp.get("/")
|
@blogs_bp.get("/")
|
||||||
async def home():
|
async def home():
|
||||||
|
"""Render the Ghost page with slug 'home' as the site homepage."""
|
||||||
|
from ..post.services.post_data import post_data as _post_data
|
||||||
|
from shared.config import config as get_config
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.services.registry import services as svc
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||||
|
|
||||||
|
p_data = await _post_data("home", g.s, include_drafts=False)
|
||||||
|
if not p_data:
|
||||||
|
# Fall back to blog index if "home" page doesn't exist yet
|
||||||
|
return redirect(host_url(url_for("blog.index")))
|
||||||
|
|
||||||
|
g.post_data = p_data
|
||||||
|
|
||||||
|
# Build the same context the post blueprint's context_processor provides
|
||||||
|
db_post_id = p_data["post"]["id"]
|
||||||
|
post_slug = p_data["post"]["slug"]
|
||||||
|
|
||||||
|
# Fetch container nav fragments from events + market
|
||||||
|
paginate_url = url_for(
|
||||||
|
'blog.post.widget_paginate',
|
||||||
|
slug=post_slug, widget_domain='calendar',
|
||||||
|
)
|
||||||
|
nav_params = {
|
||||||
|
"container_type": "page",
|
||||||
|
"container_id": str(db_post_id),
|
||||||
|
"post_slug": post_slug,
|
||||||
|
"paginate_url": paginate_url,
|
||||||
|
}
|
||||||
|
events_nav_html, market_nav_html = await fetch_fragments([
|
||||||
|
("events", "container-nav", nav_params),
|
||||||
|
("market", "container-nav", nav_params),
|
||||||
|
])
|
||||||
|
container_nav_html = events_nav_html + market_nav_html
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
**p_data,
|
||||||
|
"base_title": f"{get_config()['title']} {p_data['post']['title']}",
|
||||||
|
"container_nav_html": container_nav_html,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Page cart badge
|
||||||
|
if p_data["post"].get("is_page"):
|
||||||
|
ident = current_cart_identity()
|
||||||
|
page_summary = await svc.cart.cart_summary(
|
||||||
|
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||||
|
page_slug=post_slug,
|
||||||
|
)
|
||||||
|
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
||||||
|
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
||||||
|
|
||||||
|
if not is_htmx_request():
|
||||||
|
html = await render_template("_types/home/index.html", **ctx)
|
||||||
|
else:
|
||||||
|
html = await render_template("_types/home/_oob_elements.html", **ctx)
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
|
@blogs_bp.get("/index")
|
||||||
|
@blogs_bp.get("/index/")
|
||||||
|
async def index():
|
||||||
|
"""Blog listing — moved from / to /index."""
|
||||||
|
|
||||||
q = decode()
|
q = decode()
|
||||||
content_type = request.args.get("type", "posts")
|
content_type = request.args.get("type", "posts")
|
||||||
@@ -303,6 +364,6 @@ def register(url_prefix, title):
|
|||||||
|
|
||||||
@blogs_bp.get("/drafts/")
|
@blogs_bp.get("/drafts/")
|
||||||
async def drafts():
|
async def drafts():
|
||||||
return redirect(host_url(url_for("blog.home")) + "?drafts=1")
|
return redirect(host_url(url_for("blog.index")) + "?drafts=1")
|
||||||
|
|
||||||
return blogs_bp
|
return blogs_bp
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from ..ghost_db import DBClient # adjust import path
|
from ..ghost_db import DBClient # adjust import path
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from models.ghost_content import PostLike
|
from models.ghost_content import PostLike
|
||||||
from models.calendars import CalendarEntry, CalendarEntryPost
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
from quart import g
|
from quart import g
|
||||||
|
|
||||||
async def posts_data(
|
async def posts_data(
|
||||||
@@ -85,32 +87,16 @@ async def posts_data(
|
|||||||
for post in posts:
|
for post in posts:
|
||||||
post["is_liked"] = False
|
post["is_liked"] = False
|
||||||
|
|
||||||
# Fetch associated entries for each post
|
# Fetch card decoration fragments from events
|
||||||
# Get all confirmed entries associated with these posts
|
card_widgets_html = {}
|
||||||
from sqlalchemy.orm import selectinload
|
if post_ids:
|
||||||
entries_result = await session.execute(
|
post_slugs = [p.get("slug", "") for p in posts]
|
||||||
select(CalendarEntry, CalendarEntryPost.post_id)
|
cards_html = await fetch_fragment("events", "container-cards", params={
|
||||||
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
|
"post_ids": ",".join(str(pid) for pid in post_ids),
|
||||||
.options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar
|
"post_slugs": ",".join(post_slugs),
|
||||||
.where(
|
})
|
||||||
CalendarEntryPost.post_id.in_(post_ids),
|
if cards_html:
|
||||||
CalendarEntryPost.deleted_at.is_(None),
|
card_widgets_html = _parse_card_fragments(cards_html)
|
||||||
CalendarEntry.deleted_at.is_(None),
|
|
||||||
CalendarEntry.state == "confirmed"
|
|
||||||
)
|
|
||||||
.order_by(CalendarEntry.start_at.asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
# Group entries by post_id
|
|
||||||
entries_by_post = {}
|
|
||||||
for entry, post_id in entries_result:
|
|
||||||
if post_id not in entries_by_post:
|
|
||||||
entries_by_post[post_id] = []
|
|
||||||
entries_by_post[post_id].append(entry)
|
|
||||||
|
|
||||||
# Add associated_entries to each post
|
|
||||||
for post in posts:
|
|
||||||
post["associated_entries"] = entries_by_post.get(post["id"], [])
|
|
||||||
|
|
||||||
tags=await client.list_tags(
|
tags=await client.list_tags(
|
||||||
limit=50000
|
limit=50000
|
||||||
@@ -134,4 +120,23 @@ async def posts_data(
|
|||||||
"draft_count": draft_count,
|
"draft_count": draft_count,
|
||||||
"tag_groups": tag_groups,
|
"tag_groups": tag_groups,
|
||||||
"selected_groups": selected_groups,
|
"selected_groups": selected_groups,
|
||||||
|
"card_widgets_html": card_widgets_html,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Regex to extract per-post blocks delimited by comment markers
|
||||||
|
_CARD_MARKER_RE = re.compile(
|
||||||
|
r'<!-- card-widget:(\d+) -->(.*?)<!-- /card-widget:\1 -->',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_card_fragments(html: str) -> dict[str, str]:
|
||||||
|
"""Parse the container-cards fragment into {post_id_str: html} dict."""
|
||||||
|
result = {}
|
||||||
|
for m in _CARD_MARKER_RE.finditer(html):
|
||||||
|
post_id_str = m.group(1)
|
||||||
|
inner = m.group(2).strip()
|
||||||
|
if inner:
|
||||||
|
result[post_id_str] = inner
|
||||||
|
return result
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from ..ghost.ghost_sync import (
|
|||||||
sync_single_author,
|
sync_single_author,
|
||||||
sync_single_tag,
|
sync_single_tag,
|
||||||
)
|
)
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
from suma_browser.app.csrf import csrf_exempt
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
Internal JSON API for the coop app.
|
|
||||||
|
|
||||||
These endpoints are called by other apps (market, cart) over HTTP
|
|
||||||
to fetch Ghost CMS content and menu items without importing blog services.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from models.menu_item import MenuItem
|
|
||||||
from suma_browser.app.csrf import csrf_exempt
|
|
||||||
|
|
||||||
|
|
||||||
def register() -> Blueprint:
|
|
||||||
bp = Blueprint("coop_api", __name__, url_prefix="/internal")
|
|
||||||
|
|
||||||
@bp.get("/menu-items")
|
|
||||||
@csrf_exempt
|
|
||||||
async def menu_items():
|
|
||||||
"""
|
|
||||||
Return all active menu items as lightweight JSON.
|
|
||||||
Called by market and cart apps to render the nav.
|
|
||||||
"""
|
|
||||||
result = await g.s.execute(
|
|
||||||
select(MenuItem)
|
|
||||||
.where(MenuItem.deleted_at.is_(None))
|
|
||||||
.options(selectinload(MenuItem.post))
|
|
||||||
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
|
|
||||||
)
|
|
||||||
items = result.scalars().all()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": mi.id,
|
|
||||||
"post": {
|
|
||||||
"title": mi.post.title if mi.post else None,
|
|
||||||
"slug": mi.post.slug if mi.post else None,
|
|
||||||
"feature_image": mi.post.feature_image if mi.post else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for mi in items
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/post/<slug>")
|
|
||||||
@csrf_exempt
|
|
||||||
async def post_by_slug(slug: str):
|
|
||||||
"""
|
|
||||||
Return a Ghost post's key fields by slug.
|
|
||||||
Called by market app for the landing page.
|
|
||||||
"""
|
|
||||||
from suma_browser.app.bp.blog.ghost_db import DBClient
|
|
||||||
|
|
||||||
client = DBClient(g.s)
|
|
||||||
posts = await client.posts_by_slug(slug, include_drafts=False)
|
|
||||||
|
|
||||||
if not posts:
|
|
||||||
return jsonify(None), 404
|
|
||||||
|
|
||||||
post, original_post = posts[0]
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"post": {
|
|
||||||
"id": post.get("id"),
|
|
||||||
"title": post.get("title"),
|
|
||||||
"html": post.get("html"),
|
|
||||||
"custom_excerpt": post.get("custom_excerpt"),
|
|
||||||
"feature_image": post.get("feature_image"),
|
|
||||||
"slug": post.get("slug"),
|
|
||||||
},
|
|
||||||
"original_post": {
|
|
||||||
"id": getattr(original_post, "id", None),
|
|
||||||
"title": getattr(original_post, "title", None),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return bp
|
|
||||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .routes import register as register_fragments
|
||||||
52
bp/fragments/routes.py
Normal file
52
bp/fragments/routes.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Blog app fragment endpoints.
|
||||||
|
|
||||||
|
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
|
by other coop apps via the fragment client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, g, render_template, request
|
||||||
|
|
||||||
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.services.navigation import get_navigation_tree
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
|
# Registry of fragment handlers: type -> async callable returning HTML str
|
||||||
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
async def _require_fragment_header():
|
||||||
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
|
return Response("", status=403)
|
||||||
|
|
||||||
|
@bp.get("/<fragment_type>")
|
||||||
|
async def get_fragment(fragment_type: str):
|
||||||
|
handler = _handlers.get(fragment_type)
|
||||||
|
if handler is None:
|
||||||
|
return Response("", status=200, content_type="text/html")
|
||||||
|
html = await handler()
|
||||||
|
return Response(html, status=200, content_type="text/html")
|
||||||
|
|
||||||
|
# --- nav-tree fragment ---
|
||||||
|
async def _nav_tree_handler():
|
||||||
|
app_name = request.args.get("app_name", "")
|
||||||
|
path = request.args.get("path", "/")
|
||||||
|
first_seg = path.strip("/").split("/")[0]
|
||||||
|
menu_items = await get_navigation_tree(g.s)
|
||||||
|
return await render_template(
|
||||||
|
"fragments/nav_tree.html",
|
||||||
|
menu_items=menu_items,
|
||||||
|
frag_app_name=app_name,
|
||||||
|
frag_first_seg=first_seg,
|
||||||
|
)
|
||||||
|
|
||||||
|
_handlers["nav-tree"] = _nav_tree_handler
|
||||||
|
|
||||||
|
# Store handlers dict on blueprint so app code can register handlers
|
||||||
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from quart import Blueprint, render_template, make_response, request, jsonify, g
|
from quart import Blueprint, render_template, make_response, request, jsonify, g
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from .services.menu_items import (
|
from .services.menu_items import (
|
||||||
get_all_menu_items,
|
get_all_menu_items,
|
||||||
get_menu_item_by_id,
|
get_menu_item_by_id,
|
||||||
@@ -12,7 +12,7 @@ from .services.menu_items import (
|
|||||||
search_pages,
|
search_pages,
|
||||||
MenuItemError,
|
MenuItemError,
|
||||||
)
|
)
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||||
|
|||||||
@@ -2,38 +2,32 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.menu_item import MenuItem
|
from shared.models.menu_node import MenuNode
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
|
from shared.services.relationships import attach_child, detach_child
|
||||||
|
|
||||||
|
|
||||||
class MenuItemError(ValueError):
|
class MenuItemError(ValueError):
|
||||||
"""Base error for menu item service operations."""
|
"""Base error for menu item service operations."""
|
||||||
|
|
||||||
|
|
||||||
async def get_all_menu_items(session: AsyncSession) -> list[MenuItem]:
|
async def get_all_menu_items(session: AsyncSession) -> list[MenuNode]:
|
||||||
"""
|
"""
|
||||||
Get all menu items (excluding deleted), ordered by sort_order.
|
Get all menu nodes (excluding deleted), ordered by sort_order.
|
||||||
Eagerly loads the post relationship.
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(MenuItem)
|
select(MenuNode)
|
||||||
.where(MenuItem.deleted_at.is_(None))
|
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
|
||||||
.options(selectinload(MenuItem.post))
|
.order_by(MenuNode.sort_order.asc(), MenuNode.id.asc())
|
||||||
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
|
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuItem | None:
|
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuNode | None:
|
||||||
"""Get a menu item by ID (excluding deleted)."""
|
"""Get a menu node by ID (excluding deleted)."""
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(MenuItem)
|
select(MenuNode)
|
||||||
.where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None))
|
.where(MenuNode.id == item_id, MenuNode.deleted_at.is_(None))
|
||||||
.options(selectinload(MenuItem.post))
|
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -42,9 +36,9 @@ async def create_menu_item(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
post_id: int,
|
post_id: int,
|
||||||
sort_order: int | None = None
|
sort_order: int | None = None
|
||||||
) -> MenuItem:
|
) -> MenuNode:
|
||||||
"""
|
"""
|
||||||
Create a new menu item.
|
Create a MenuNode + ContainerRelation for a page.
|
||||||
If sort_order is not provided, adds to end of list.
|
If sort_order is not provided, adds to end of list.
|
||||||
"""
|
"""
|
||||||
# Verify post exists and is a page
|
# Verify post exists and is a page
|
||||||
@@ -60,32 +54,35 @@ async def create_menu_item(
|
|||||||
# If no sort_order provided, add to end
|
# If no sort_order provided, add to end
|
||||||
if sort_order is None:
|
if sort_order is None:
|
||||||
max_order = await session.scalar(
|
max_order = await session.scalar(
|
||||||
select(func.max(MenuItem.sort_order))
|
select(func.max(MenuNode.sort_order))
|
||||||
.where(MenuItem.deleted_at.is_(None))
|
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
|
||||||
)
|
)
|
||||||
sort_order = (max_order or 0) + 1
|
sort_order = (max_order or 0) + 1
|
||||||
|
|
||||||
# Check for duplicate (same post, not deleted)
|
# Check for duplicate (same page, not deleted)
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(MenuItem).where(
|
select(MenuNode).where(
|
||||||
MenuItem.post_id == post_id,
|
MenuNode.container_type == "page",
|
||||||
MenuItem.deleted_at.is_(None)
|
MenuNode.container_id == post_id,
|
||||||
|
MenuNode.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
raise MenuItemError(f"Menu item for this page already exists.")
|
raise MenuItemError("Menu item for this page already exists.")
|
||||||
|
|
||||||
menu_item = MenuItem(
|
menu_node = MenuNode(
|
||||||
post_id=post_id,
|
container_type="page",
|
||||||
sort_order=sort_order
|
container_id=post_id,
|
||||||
|
label=post.title,
|
||||||
|
slug=post.slug,
|
||||||
|
feature_image=post.feature_image,
|
||||||
|
sort_order=sort_order,
|
||||||
)
|
)
|
||||||
session.add(menu_item)
|
session.add(menu_node)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
|
||||||
|
|
||||||
# Reload with post relationship
|
return menu_node
|
||||||
await session.refresh(menu_item, ["post"])
|
|
||||||
|
|
||||||
return menu_item
|
|
||||||
|
|
||||||
|
|
||||||
async def update_menu_item(
|
async def update_menu_item(
|
||||||
@@ -93,10 +90,10 @@ async def update_menu_item(
|
|||||||
item_id: int,
|
item_id: int,
|
||||||
post_id: int | None = None,
|
post_id: int | None = None,
|
||||||
sort_order: int | None = None
|
sort_order: int | None = None
|
||||||
) -> MenuItem:
|
) -> MenuNode:
|
||||||
"""Update an existing menu item."""
|
"""Update an existing menu node."""
|
||||||
menu_item = await get_menu_item_by_id(session, item_id)
|
menu_node = await get_menu_item_by_id(session, item_id)
|
||||||
if not menu_item:
|
if not menu_node:
|
||||||
raise MenuItemError(f"Menu item {item_id} not found.")
|
raise MenuItemError(f"Menu item {item_id} not found.")
|
||||||
|
|
||||||
if post_id is not None:
|
if post_id is not None:
|
||||||
@@ -110,36 +107,45 @@ async def update_menu_item(
|
|||||||
if not post.is_page:
|
if not post.is_page:
|
||||||
raise MenuItemError("Only pages can be added as menu items, not posts.")
|
raise MenuItemError("Only pages can be added as menu items, not posts.")
|
||||||
|
|
||||||
# Check for duplicate (same post, different menu item)
|
# Check for duplicate (same page, different menu node)
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(MenuItem).where(
|
select(MenuNode).where(
|
||||||
MenuItem.post_id == post_id,
|
MenuNode.container_type == "page",
|
||||||
MenuItem.id != item_id,
|
MenuNode.container_id == post_id,
|
||||||
MenuItem.deleted_at.is_(None)
|
MenuNode.id != item_id,
|
||||||
|
MenuNode.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
raise MenuItemError(f"Menu item for this page already exists.")
|
raise MenuItemError("Menu item for this page already exists.")
|
||||||
|
|
||||||
menu_item.post_id = post_id
|
old_post_id = menu_node.container_id
|
||||||
|
menu_node.container_id = post_id
|
||||||
|
menu_node.label = post.title
|
||||||
|
menu_node.slug = post.slug
|
||||||
|
menu_node.feature_image = post.feature_image
|
||||||
|
|
||||||
if sort_order is not None:
|
if sort_order is not None:
|
||||||
menu_item.sort_order = sort_order
|
menu_node.sort_order = sort_order
|
||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(menu_item, ["post"])
|
|
||||||
|
|
||||||
return menu_item
|
if post_id is not None and post_id != old_post_id:
|
||||||
|
await detach_child(session, "page", old_post_id, "menu_node", menu_node.id)
|
||||||
|
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
|
||||||
|
|
||||||
|
return menu_node
|
||||||
|
|
||||||
|
|
||||||
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
||||||
"""Soft delete a menu item."""
|
"""Soft delete a menu node."""
|
||||||
menu_item = await get_menu_item_by_id(session, item_id)
|
menu_node = await get_menu_item_by_id(session, item_id)
|
||||||
if not menu_item:
|
if not menu_node:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
menu_item.deleted_at = func.now()
|
menu_node.deleted_at = func.now()
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
await detach_child(session, "page", menu_node.container_id, "menu_node", menu_node.id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -147,17 +153,17 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
|||||||
async def reorder_menu_items(
|
async def reorder_menu_items(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
item_ids: list[int]
|
item_ids: list[int]
|
||||||
) -> list[MenuItem]:
|
) -> list[MenuNode]:
|
||||||
"""
|
"""
|
||||||
Reorder menu items by providing a list of IDs in desired order.
|
Reorder menu nodes by providing a list of IDs in desired order.
|
||||||
Updates sort_order for each item.
|
Updates sort_order for each node.
|
||||||
"""
|
"""
|
||||||
items = []
|
items = []
|
||||||
for index, item_id in enumerate(item_ids):
|
for index, item_id in enumerate(item_ids):
|
||||||
menu_item = await get_menu_item_by_id(session, item_id)
|
menu_node = await get_menu_item_by_id(session, item_id)
|
||||||
if menu_item:
|
if menu_node:
|
||||||
menu_item.sort_order = index
|
menu_node.sort_order = index
|
||||||
items.append(menu_item)
|
items.append(menu_node)
|
||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
@@ -174,7 +180,6 @@ async def search_pages(
|
|||||||
Search for pages (not posts) by title.
|
Search for pages (not posts) by title.
|
||||||
Returns (pages, total_count).
|
Returns (pages, total_count).
|
||||||
"""
|
"""
|
||||||
# Build search filter
|
|
||||||
filters = [
|
filters = [
|
||||||
Post.is_page == True, # noqa: E712
|
Post.is_page == True, # noqa: E712
|
||||||
Post.status == "published",
|
Post.status == "published",
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from quart import (
|
|||||||
redirect,
|
redirect,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from suma_browser.app.authz import require_admin, require_post_author
|
from shared.browser.app.authz import require_admin, require_post_author
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||||
@@ -21,8 +21,8 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def admin(slug: str):
|
async def admin(slug: str):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from models.page_config import PageConfig
|
from shared.models.page_config import PageConfig
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
# Load features for page admin
|
# Load features for page admin
|
||||||
@@ -33,7 +33,7 @@ def register():
|
|||||||
sumup_checkout_prefix = ""
|
sumup_checkout_prefix = ""
|
||||||
if post.get("is_page"):
|
if post.get("is_page"):
|
||||||
pc = (await g.s.execute(
|
pc = (await g.s.execute(
|
||||||
sa_select(PageConfig).where(PageConfig.post_id == post["id"])
|
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post["id"])
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if pc:
|
if pc:
|
||||||
features = pc.features or {}
|
features = pc.features or {}
|
||||||
@@ -62,7 +62,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def update_features(slug: str):
|
async def update_features(slug: str):
|
||||||
"""Update PageConfig.features for a page."""
|
"""Update PageConfig.features for a page."""
|
||||||
from models.page_config import PageConfig
|
from shared.models.page_config import PageConfig
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
@@ -76,12 +76,14 @@ def register():
|
|||||||
|
|
||||||
# Load or create PageConfig
|
# Load or create PageConfig
|
||||||
pc = (await g.s.execute(
|
pc = (await g.s.execute(
|
||||||
sa_select(PageConfig).where(PageConfig.post_id == post_id)
|
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if pc is None:
|
if pc is None:
|
||||||
pc = PageConfig(post_id=post_id, features={})
|
pc = PageConfig(container_type="page", container_id=post_id, features={})
|
||||||
g.s.add(pc)
|
g.s.add(pc)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
from shared.services.relationships import attach_child
|
||||||
|
await attach_child(g.s, "page", post_id, "page_config", pc.id)
|
||||||
|
|
||||||
# Parse request body
|
# Parse request body
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
@@ -127,7 +129,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def update_sumup(slug: str):
|
async def update_sumup(slug: str):
|
||||||
"""Update PageConfig SumUp credentials for a page."""
|
"""Update PageConfig SumUp credentials for a page."""
|
||||||
from models.page_config import PageConfig
|
from shared.models.page_config import PageConfig
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
@@ -138,12 +140,14 @@ def register():
|
|||||||
post_id = post["id"]
|
post_id = post["id"]
|
||||||
|
|
||||||
pc = (await g.s.execute(
|
pc = (await g.s.execute(
|
||||||
sa_select(PageConfig).where(PageConfig.post_id == post_id)
|
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if pc is None:
|
if pc is None:
|
||||||
pc = PageConfig(post_id=post_id, features={})
|
pc = PageConfig(container_type="page", container_id=post_id, features={})
|
||||||
g.s.add(pc)
|
g.s.add(pc)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
from shared.services.relationships import attach_child
|
||||||
|
await attach_child(g.s, "page", post_id, "page_config", pc.id)
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
merchant_code = (form.get("merchant_code") or "").strip()
|
merchant_code = (form.get("merchant_code") or "").strip()
|
||||||
@@ -187,13 +191,12 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def calendar_view(slug: str, calendar_id: int):
|
async def calendar_view(slug: str, calendar_id: int):
|
||||||
"""Show calendar month view for browsing entries"""
|
"""Show calendar month view for browsing entries"""
|
||||||
from models.calendars import Calendar
|
from shared.models.calendars import Calendar
|
||||||
|
from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks
|
||||||
|
from shared.services.registry import services
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from quart import request
|
|
||||||
import calendar as pycalendar
|
import calendar as pycalendar
|
||||||
from ...calendar.services.calendar_view import parse_int_arg, add_months, build_calendar_weeks
|
|
||||||
from ...calendar.services import get_visible_entries_for_period
|
|
||||||
from quart import session as qsession
|
from quart import session as qsession
|
||||||
from ..services.entry_associations import get_post_entry_ids
|
from ..services.entry_associations import get_post_entry_ids
|
||||||
|
|
||||||
@@ -231,15 +234,13 @@ def register():
|
|||||||
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
|
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
user = getattr(g, "user", None)
|
user = getattr(g, "user", None)
|
||||||
|
user_id = user.id if user else None
|
||||||
|
is_admin = bool(user and getattr(user, "is_admin", False))
|
||||||
session_id = qsession.get("calendar_sid")
|
session_id = qsession.get("calendar_sid")
|
||||||
|
|
||||||
visible = await get_visible_entries_for_period(
|
month_entries = await services.calendar.visible_entries_for_period(
|
||||||
sess=g.s,
|
g.s, calendar_obj.id, period_start, period_end,
|
||||||
calendar_id=calendar_obj.id,
|
user_id=user_id, is_admin=is_admin, session_id=session_id,
|
||||||
period_start=period_start,
|
|
||||||
period_end=period_end,
|
|
||||||
user=user,
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get associated entry IDs for this post
|
# Get associated entry IDs for this post
|
||||||
@@ -260,7 +261,7 @@ def register():
|
|||||||
next_month_year=next_month_year,
|
next_month_year=next_month_year,
|
||||||
prev_year=prev_year,
|
prev_year=prev_year,
|
||||||
next_year=next_year,
|
next_year=next_year,
|
||||||
month_entries=visible.merged_entries,
|
month_entries=month_entries,
|
||||||
associated_entry_ids=associated_entry_ids,
|
associated_entry_ids=associated_entry_ids,
|
||||||
)
|
)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
@@ -269,7 +270,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def entries(slug: str):
|
async def entries(slug: str):
|
||||||
from ..services.entry_associations import get_post_entry_ids
|
from ..services.entry_associations import get_post_entry_ids
|
||||||
from models.calendars import Calendar
|
from shared.models.calendars import Calendar
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
@@ -305,7 +306,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def toggle_entry(slug: str, entry_id: int):
|
async def toggle_entry(slug: str, entry_id: int):
|
||||||
from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries
|
from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries
|
||||||
from models.calendars import Calendar
|
from shared.models.calendars import Calendar
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
@@ -339,7 +340,7 @@ def register():
|
|||||||
calendars = (
|
calendars = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(Calendar)
|
select(Calendar)
|
||||||
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
|
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
|
||||||
.order_by(Calendar.name.asc())
|
.order_by(Calendar.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -366,7 +367,8 @@ def register():
|
|||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
ghost_id = g.post_data["post"]["ghost_id"]
|
||||||
ghost_post = await get_post_for_edit(ghost_id)
|
is_page = bool(g.post_data["post"].get("is_page"))
|
||||||
|
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||||
save_success = request.args.get("saved") == "1"
|
save_success = request.args.get("saved") == "1"
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
@@ -388,10 +390,11 @@ def register():
|
|||||||
@require_post_author
|
@require_post_author
|
||||||
async def settings_save(slug: str):
|
async def settings_save(slug: str):
|
||||||
from ...blog.ghost.ghost_posts import update_post_settings
|
from ...blog.ghost.ghost_posts import update_post_settings
|
||||||
from ...blog.ghost.ghost_sync import sync_single_post
|
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
|
||||||
from suma_browser.app.redis_cacher import invalidate_tag_cache
|
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
ghost_id = g.post_data["post"]["ghost_id"]
|
||||||
|
is_page = bool(g.post_data["post"].get("is_page"))
|
||||||
form = await request.form
|
form = await request.form
|
||||||
|
|
||||||
updated_at = form.get("updated_at", "")
|
updated_at = form.get("updated_at", "")
|
||||||
@@ -435,10 +438,14 @@ def register():
|
|||||||
await update_post_settings(
|
await update_post_settings(
|
||||||
ghost_id=ghost_id,
|
ghost_id=ghost_id,
|
||||||
updated_at=updated_at,
|
updated_at=updated_at,
|
||||||
|
is_page=is_page,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync to local DB
|
# 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 sync_single_post(g.s, ghost_id)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
@@ -452,11 +459,12 @@ def register():
|
|||||||
@require_post_author
|
@require_post_author
|
||||||
async def edit(slug: str):
|
async def edit(slug: str):
|
||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||||
from models.ghost_membership_entities import GhostNewsletter
|
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
ghost_id = g.post_data["post"]["ghost_id"]
|
||||||
ghost_post = await get_post_for_edit(ghost_id)
|
is_page = bool(g.post_data["post"].get("is_page"))
|
||||||
|
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||||
save_success = request.args.get("saved") == "1"
|
save_success = request.args.get("saved") == "1"
|
||||||
|
|
||||||
newsletters = (await g.s.execute(
|
newsletters = (await g.s.execute(
|
||||||
@@ -486,10 +494,11 @@ def register():
|
|||||||
import json
|
import json
|
||||||
from ...blog.ghost.ghost_posts import update_post
|
from ...blog.ghost.ghost_posts import update_post
|
||||||
from ...blog.ghost.lexical_validator import validate_lexical
|
from ...blog.ghost.lexical_validator import validate_lexical
|
||||||
from ...blog.ghost.ghost_sync import sync_single_post
|
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
|
||||||
from suma_browser.app.redis_cacher import invalidate_tag_cache
|
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
ghost_id = g.post_data["post"]["ghost_id"]
|
||||||
|
is_page = bool(g.post_data["post"].get("is_page"))
|
||||||
form = await request.form
|
form = await request.form
|
||||||
title = form.get("title", "").strip()
|
title = form.get("title", "").strip()
|
||||||
lexical_raw = form.get("lexical", "")
|
lexical_raw = form.get("lexical", "")
|
||||||
@@ -506,7 +515,7 @@ def register():
|
|||||||
lexical_doc = json.loads(lexical_raw)
|
lexical_doc = json.loads(lexical_raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||||
ghost_post = await get_post_for_edit(ghost_id)
|
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/post_edit/index.html",
|
"_types/post_edit/index.html",
|
||||||
ghost_post=ghost_post,
|
ghost_post=ghost_post,
|
||||||
@@ -517,7 +526,7 @@ def register():
|
|||||||
ok, reason = validate_lexical(lexical_doc)
|
ok, reason = validate_lexical(lexical_doc)
|
||||||
if not ok:
|
if not ok:
|
||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
from ...blog.ghost.ghost_posts import get_post_for_edit
|
||||||
ghost_post = await get_post_for_edit(ghost_id)
|
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/post_edit/index.html",
|
"_types/post_edit/index.html",
|
||||||
ghost_post=ghost_post,
|
ghost_post=ghost_post,
|
||||||
@@ -534,6 +543,7 @@ def register():
|
|||||||
feature_image=feature_image,
|
feature_image=feature_image,
|
||||||
custom_excerpt=custom_excerpt,
|
custom_excerpt=custom_excerpt,
|
||||||
feature_image_caption=feature_image_caption,
|
feature_image_caption=feature_image_caption,
|
||||||
|
is_page=is_page,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Publish workflow
|
# Publish workflow
|
||||||
@@ -564,10 +574,14 @@ def register():
|
|||||||
title=None,
|
title=None,
|
||||||
updated_at=ghost_post["updated_at"],
|
updated_at=ghost_post["updated_at"],
|
||||||
status=status,
|
status=status,
|
||||||
|
is_page=is_page,
|
||||||
**email_kwargs,
|
**email_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync to local DB
|
# 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 sync_single_post(g.s, ghost_id)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
@@ -599,20 +613,14 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def markets(slug: str):
|
async def markets(slug: str):
|
||||||
"""List markets for this page."""
|
"""List markets for this page."""
|
||||||
from models.market_place import MarketPlace
|
from shared.services.registry import services
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
|
|
||||||
post = (g.post_data or {}).get("post", {})
|
post = (g.post_data or {}).get("post", {})
|
||||||
post_id = post.get("id")
|
post_id = post.get("id")
|
||||||
if not post_id:
|
if not post_id:
|
||||||
return await make_response("Post not found", 404)
|
return await make_response("Post not found", 404)
|
||||||
|
|
||||||
page_markets = (await g.s.execute(
|
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
|
||||||
sa_select(MarketPlace).where(
|
|
||||||
MarketPlace.post_id == post_id,
|
|
||||||
MarketPlace.deleted_at.is_(None),
|
|
||||||
).order_by(MarketPlace.name)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/post/admin/_markets_panel.html",
|
"_types/post/admin/_markets_panel.html",
|
||||||
@@ -626,8 +634,7 @@ def register():
|
|||||||
async def create_market(slug: str):
|
async def create_market(slug: str):
|
||||||
"""Create a new market for this page."""
|
"""Create a new market for this page."""
|
||||||
from ..services.markets import create_market as _create_market, MarketError
|
from ..services.markets import create_market as _create_market, MarketError
|
||||||
from models.market_place import MarketPlace
|
from shared.services.registry import services
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
post = (g.post_data or {}).get("post", {})
|
post = (g.post_data or {}).get("post", {})
|
||||||
@@ -644,12 +651,7 @@ def register():
|
|||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
# Return updated markets list
|
# Return updated markets list
|
||||||
page_markets = (await g.s.execute(
|
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
|
||||||
sa_select(MarketPlace).where(
|
|
||||||
MarketPlace.post_id == post_id,
|
|
||||||
MarketPlace.deleted_at.is_(None),
|
|
||||||
).order_by(MarketPlace.name)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/post/admin/_markets_panel.html",
|
"_types/post/admin/_markets_panel.html",
|
||||||
@@ -663,8 +665,7 @@ def register():
|
|||||||
async def delete_market(slug: str, market_slug: str):
|
async def delete_market(slug: str, market_slug: str):
|
||||||
"""Soft-delete a market."""
|
"""Soft-delete a market."""
|
||||||
from ..services.markets import soft_delete_market
|
from ..services.markets import soft_delete_market
|
||||||
from models.market_place import MarketPlace
|
from shared.services.registry import services
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
post = (g.post_data or {}).get("post", {})
|
post = (g.post_data or {}).get("post", {})
|
||||||
@@ -675,12 +676,7 @@ def register():
|
|||||||
return jsonify({"error": "Market not found"}), 404
|
return jsonify({"error": "Market not found"}), 404
|
||||||
|
|
||||||
# Return updated markets list
|
# Return updated markets list
|
||||||
page_markets = (await g.s.execute(
|
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
|
||||||
sa_select(MarketPlace).where(
|
|
||||||
MarketPlace.post_id == post_id,
|
|
||||||
MarketPlace.deleted_at.is_(None),
|
|
||||||
).order_by(MarketPlace.name)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/post/admin/_markets_panel.html",
|
"_types/post/admin/_markets_panel.html",
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ from quart import (
|
|||||||
Blueprint,
|
Blueprint,
|
||||||
abort,
|
abort,
|
||||||
url_for,
|
url_for,
|
||||||
|
request,
|
||||||
)
|
)
|
||||||
from .services.post_data import post_data
|
from .services.post_data import post_data
|
||||||
from .services.post_operations import toggle_post_like
|
from .services.post_operations import toggle_post_like
|
||||||
from models.calendars import Calendar
|
from shared.services.registry import services
|
||||||
from models.market_place import MarketPlace
|
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||||
|
|
||||||
|
|
||||||
from .admin.routes import register as register_admin
|
from .admin.routes import register as register_admin
|
||||||
from config import config
|
from shared.config import config
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("post", __name__, url_prefix='/<slug>')
|
bp = Blueprint("post", __name__, url_prefix='/<slug>')
|
||||||
@@ -64,51 +64,44 @@ def register():
|
|||||||
async def context():
|
async def context():
|
||||||
p_data = getattr(g, "post_data", None)
|
p_data = getattr(g, "post_data", None)
|
||||||
if p_data:
|
if p_data:
|
||||||
from .services.entry_associations import get_associated_entries
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
from shared.internal_api import get as api_get
|
|
||||||
|
|
||||||
db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
|
db_post_id = (g.post_data.get("post") or {}).get("id")
|
||||||
calendars = (
|
post_slug = (g.post_data.get("post") or {}).get("slug", "")
|
||||||
await g.s.execute(
|
|
||||||
select(Calendar)
|
# Fetch container nav fragments from events + market
|
||||||
.where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None))
|
paginate_url = url_for(
|
||||||
.order_by(Calendar.name.asc())
|
'blog.post.widget_paginate',
|
||||||
|
slug=post_slug, widget_domain='calendar',
|
||||||
)
|
)
|
||||||
).scalars().all()
|
nav_params = {
|
||||||
|
"container_type": "page",
|
||||||
markets = (
|
"container_id": str(db_post_id),
|
||||||
await g.s.execute(
|
"post_slug": post_slug,
|
||||||
select(MarketPlace)
|
"paginate_url": paginate_url,
|
||||||
.where(MarketPlace.post_id == db_post_id, MarketPlace.deleted_at.is_(None))
|
}
|
||||||
.order_by(MarketPlace.name.asc())
|
events_nav_html, market_nav_html = await fetch_fragments([
|
||||||
)
|
("events", "container-nav", nav_params),
|
||||||
).scalars().all()
|
("market", "container-nav", nav_params),
|
||||||
|
])
|
||||||
# Fetch associated entries for nav display
|
container_nav_html = events_nav_html + market_nav_html
|
||||||
associated_entries = await get_associated_entries(g.s, db_post_id)
|
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
**p_data,
|
**p_data,
|
||||||
"base_title": f"{config()['title']} {p_data['post']['title']}",
|
"base_title": f"{config()['title']} {p_data['post']['title']}",
|
||||||
"calendars": calendars,
|
"container_nav_html": container_nav_html,
|
||||||
"markets": markets,
|
|
||||||
"associated_entries": associated_entries,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Page cart badge: fetch page-scoped cart count for pages
|
# Page cart badge via service
|
||||||
post_dict = p_data.get("post") or {}
|
post_dict = p_data.get("post") or {}
|
||||||
if post_dict.get("is_page"):
|
if post_dict.get("is_page"):
|
||||||
page_cart = await api_get(
|
ident = current_cart_identity()
|
||||||
"cart",
|
page_summary = await services.cart.cart_summary(
|
||||||
f"/internal/cart/summary?page_slug={post_dict['slug']}",
|
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||||
forward_session=True,
|
page_slug=post_dict["slug"],
|
||||||
)
|
)
|
||||||
if page_cart:
|
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
||||||
ctx["page_cart_count"] = page_cart.get("count", 0) + page_cart.get("calendar_count", 0)
|
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
||||||
ctx["page_cart_total"] = page_cart.get("total", 0) + page_cart.get("calendar_total", 0)
|
|
||||||
else:
|
|
||||||
ctx["page_cart_count"] = 0
|
|
||||||
ctx["page_cart_total"] = 0
|
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
else:
|
else:
|
||||||
@@ -130,7 +123,7 @@ def register():
|
|||||||
@bp.post("/like/toggle/")
|
@bp.post("/like/toggle/")
|
||||||
@clear_cache(tag="post.post_detail", tag_scope="user")
|
@clear_cache(tag="post.post_detail", tag_scope="user")
|
||||||
async def like_toggle(slug: str):
|
async def like_toggle(slug: str):
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
# Get post_id from g.post_data
|
# Get post_id from g.post_data
|
||||||
if not g.user:
|
if not g.user:
|
||||||
@@ -162,24 +155,25 @@ def register():
|
|||||||
)
|
)
|
||||||
return html
|
return html
|
||||||
|
|
||||||
@bp.get("/entries/")
|
@bp.get("/w/<widget_domain>/")
|
||||||
async def get_entries(slug: str):
|
async def widget_paginate(slug: str, widget_domain: str):
|
||||||
"""Get paginated associated entries for infinite scroll in nav"""
|
"""Proxies paginated widget requests to the appropriate fragment provider."""
|
||||||
from .services.entry_associations import get_associated_entries
|
|
||||||
from quart import request
|
|
||||||
|
|
||||||
page = int(request.args.get("page", 1))
|
page = int(request.args.get("page", 1))
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
|
|
||||||
result = await get_associated_entries(g.s, post_id, page=page, per_page=10)
|
if widget_domain == "calendar":
|
||||||
|
html = await fetch_fragment("events", "container-nav", params={
|
||||||
html = await render_template(
|
"container_type": "page",
|
||||||
"_types/post/_entry_items.html",
|
"container_id": str(post_id),
|
||||||
entries=result["entries"],
|
"post_slug": slug,
|
||||||
page=result["page"],
|
"page": str(page),
|
||||||
has_more=result["has_more"],
|
"paginate_url": url_for(
|
||||||
)
|
'blog.post.widget_paginate',
|
||||||
return await make_response(html)
|
slug=slug, widget_domain='calendar',
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return await make_response(html or "")
|
||||||
|
abort(404)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
|
|
||||||
from models.calendars import CalendarEntry, CalendarEntryPost, Calendar
|
from shared.services.registry import services
|
||||||
from models.ghost_content import Post
|
|
||||||
|
|
||||||
|
|
||||||
async def toggle_entry_association(
|
async def toggle_entry_association(
|
||||||
@@ -17,43 +14,14 @@ async def toggle_entry_association(
|
|||||||
Toggle association between a post and calendar entry.
|
Toggle association between a post and calendar entry.
|
||||||
Returns (is_now_associated, error_message).
|
Returns (is_now_associated, error_message).
|
||||||
"""
|
"""
|
||||||
# Check if entry exists (don't filter by deleted_at - allow associating with any entry)
|
post = await services.blog.get_post_by_id(session, post_id)
|
||||||
entry = await session.scalar(
|
|
||||||
select(CalendarEntry).where(CalendarEntry.id == entry_id)
|
|
||||||
)
|
|
||||||
if not entry:
|
|
||||||
return False, f"Calendar entry {entry_id} not found in database"
|
|
||||||
|
|
||||||
# Check if post exists
|
|
||||||
post = await session.scalar(
|
|
||||||
select(Post).where(Post.id == post_id)
|
|
||||||
)
|
|
||||||
if not post:
|
if not post:
|
||||||
return False, "Post not found"
|
return False, "Post not found"
|
||||||
|
|
||||||
# Check if association already exists
|
is_associated = await services.calendar.toggle_entry_post(
|
||||||
existing = await session.scalar(
|
session, entry_id, "post", post_id,
|
||||||
select(CalendarEntryPost).where(
|
|
||||||
CalendarEntryPost.entry_id == entry_id,
|
|
||||||
CalendarEntryPost.post_id == post_id,
|
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
|
||||||
)
|
)
|
||||||
)
|
return is_associated, None
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Remove association (soft delete)
|
|
||||||
existing.deleted_at = func.now()
|
|
||||||
await session.flush()
|
|
||||||
return False, None
|
|
||||||
else:
|
|
||||||
# Create association
|
|
||||||
association = CalendarEntryPost(
|
|
||||||
entry_id=entry_id,
|
|
||||||
post_id=post_id
|
|
||||||
)
|
|
||||||
session.add(association)
|
|
||||||
await session.flush()
|
|
||||||
return True, None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_post_entry_ids(
|
async def get_post_entry_ids(
|
||||||
@@ -64,14 +32,7 @@ async def get_post_entry_ids(
|
|||||||
Get all entry IDs associated with this post.
|
Get all entry IDs associated with this post.
|
||||||
Returns a set of entry IDs.
|
Returns a set of entry IDs.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
return await services.calendar.entry_ids_for_content(session, "post", post_id)
|
||||||
select(CalendarEntryPost.entry_id)
|
|
||||||
.where(
|
|
||||||
CalendarEntryPost.post_id == post_id,
|
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return set(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
async def get_associated_entries(
|
async def get_associated_entries(
|
||||||
@@ -82,58 +43,14 @@ async def get_associated_entries(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get paginated associated entries for this post.
|
Get paginated associated entries for this post.
|
||||||
Returns dict with entries, total_count, and has_more.
|
Returns dict with entries (CalendarEntryDTOs), total_count, and has_more.
|
||||||
"""
|
"""
|
||||||
# Get all associated entry IDs
|
entries, has_more = await services.calendar.associated_entries(
|
||||||
entry_ids_result = await session.execute(
|
session, "post", post_id, page,
|
||||||
select(CalendarEntryPost.entry_id)
|
|
||||||
.where(
|
|
||||||
CalendarEntryPost.post_id == post_id,
|
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
|
||||||
)
|
)
|
||||||
)
|
total_count = len(entries) + (page - 1) * per_page
|
||||||
entry_ids = set(entry_ids_result.scalars().all())
|
if has_more:
|
||||||
|
total_count += 1 # at least one more
|
||||||
if not entry_ids:
|
|
||||||
return {
|
|
||||||
"entries": [],
|
|
||||||
"total_count": 0,
|
|
||||||
"has_more": False,
|
|
||||||
"page": page,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get total count
|
|
||||||
from sqlalchemy import func
|
|
||||||
total_count = len(entry_ids)
|
|
||||||
|
|
||||||
# Get paginated entries ordered by start_at desc
|
|
||||||
# Only include confirmed entries
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
result = await session.execute(
|
|
||||||
select(CalendarEntry)
|
|
||||||
.where(
|
|
||||||
CalendarEntry.id.in_(entry_ids),
|
|
||||||
CalendarEntry.deleted_at.is_(None),
|
|
||||||
CalendarEntry.state == "confirmed" # Only confirmed entries in nav
|
|
||||||
)
|
|
||||||
.order_by(CalendarEntry.start_at.desc())
|
|
||||||
.limit(per_page)
|
|
||||||
.offset(offset)
|
|
||||||
)
|
|
||||||
entries = result.scalars().all()
|
|
||||||
|
|
||||||
# Recalculate total_count based on confirmed entries only
|
|
||||||
total_count = len(entries) + offset # Rough estimate
|
|
||||||
if len(entries) < per_page:
|
|
||||||
total_count = offset + len(entries)
|
|
||||||
|
|
||||||
# Load calendar relationship for each entry
|
|
||||||
for entry in entries:
|
|
||||||
await session.refresh(entry, ["calendar"])
|
|
||||||
if entry.calendar:
|
|
||||||
await session.refresh(entry.calendar, ["post"])
|
|
||||||
|
|
||||||
has_more = len(entries) == per_page # More accurate check
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import unicodedata
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.market_place import MarketPlace
|
from shared.models.page_config import PageConfig
|
||||||
from models.ghost_content import Post
|
from shared.contracts.dtos import MarketPlaceDTO
|
||||||
from models.page_config import PageConfig
|
from shared.services.registry import services
|
||||||
from suma_browser.app.utils import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
class MarketError(ValueError):
|
class MarketError(ValueError):
|
||||||
@@ -29,13 +28,13 @@ def slugify(value: str, max_len: int = 255) -> str:
|
|||||||
return value or "market"
|
return value or "market"
|
||||||
|
|
||||||
|
|
||||||
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace:
|
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise MarketError("Market name must not be empty.")
|
raise MarketError("Market name must not be empty.")
|
||||||
slug = slugify(name)
|
slug = slugify(name)
|
||||||
|
|
||||||
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
|
post = await services.blog.get_post_by_id(sess, post_id)
|
||||||
if not post:
|
if not post:
|
||||||
raise MarketError(f"Post {post_id} does not exist.")
|
raise MarketError(f"Post {post_id} does not exist.")
|
||||||
|
|
||||||
@@ -43,46 +42,20 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
|||||||
raise MarketError("Markets can only be created on pages, not posts.")
|
raise MarketError("Markets can only be created on pages, not posts.")
|
||||||
|
|
||||||
pc = (await sess.execute(
|
pc = (await sess.execute(
|
||||||
select(PageConfig).where(PageConfig.post_id == post_id)
|
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if pc is None or not (pc.features or {}).get("market"):
|
if pc is None or not (pc.features or {}).get("market"):
|
||||||
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
|
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
|
||||||
|
|
||||||
# Look for existing (including soft-deleted)
|
try:
|
||||||
existing = (await sess.execute(
|
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
|
||||||
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
|
except ValueError as e:
|
||||||
)).scalar_one_or_none()
|
raise MarketError(str(e)) from e
|
||||||
|
|
||||||
if existing:
|
|
||||||
if existing.deleted_at is not None:
|
|
||||||
existing.deleted_at = None # revive
|
|
||||||
existing.name = name
|
|
||||||
await sess.flush()
|
|
||||||
return existing
|
|
||||||
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
|
|
||||||
|
|
||||||
market = MarketPlace(post_id=post_id, name=name, slug=slug)
|
|
||||||
sess.add(market)
|
|
||||||
await sess.flush()
|
|
||||||
return market
|
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||||
market = (
|
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||||
await sess.execute(
|
if not post:
|
||||||
select(MarketPlace)
|
|
||||||
.join(Post, MarketPlace.post_id == Post.id)
|
|
||||||
.where(
|
|
||||||
Post.slug == post_slug,
|
|
||||||
MarketPlace.slug == market_slug,
|
|
||||||
MarketPlace.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
if not market:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
market.deleted_at = utcnow()
|
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
|
||||||
await sess.flush()
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from quart import Blueprint, render_template, make_response, request, g, abort
|
|||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from suma_browser.app.authz import require_login
|
from shared.browser.app.authz import require_login
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from models import Snippet
|
from models import Snippet
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
84
config/app-config.yaml
Normal file
84
config/app-config.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# App-wide settings
|
||||||
|
base_host: "wholesale.suma.coop"
|
||||||
|
base_login: https://wholesale.suma.coop/customer/account/login/
|
||||||
|
base_url: https://wholesale.suma.coop/
|
||||||
|
title: Rose Ash
|
||||||
|
market_root: /market
|
||||||
|
market_title: Market
|
||||||
|
blog_root: /
|
||||||
|
blog_title: all the news
|
||||||
|
cart_root: /cart
|
||||||
|
app_urls:
|
||||||
|
blog: "http://localhost:8000"
|
||||||
|
market: "http://localhost:8001"
|
||||||
|
cart: "http://localhost:8002"
|
||||||
|
events: "http://localhost:8003"
|
||||||
|
federation: "http://localhost:8004"
|
||||||
|
cache:
|
||||||
|
fs_root: _snapshot # <- absolute path to your snapshot dir
|
||||||
|
categories:
|
||||||
|
allow:
|
||||||
|
Basics: basics
|
||||||
|
Branded Goods: branded-goods
|
||||||
|
Chilled: chilled
|
||||||
|
Frozen: frozen
|
||||||
|
Non-foods: non-foods
|
||||||
|
Supplements: supplements
|
||||||
|
Christmas: christmas
|
||||||
|
slugs:
|
||||||
|
skip:
|
||||||
|
- ""
|
||||||
|
- customer
|
||||||
|
- account
|
||||||
|
- checkout
|
||||||
|
- wishlist
|
||||||
|
- sales
|
||||||
|
- contact
|
||||||
|
- privacy-policy
|
||||||
|
- terms-and-conditions
|
||||||
|
- delivery
|
||||||
|
- catalogsearch
|
||||||
|
- quickorder
|
||||||
|
- apply
|
||||||
|
- search
|
||||||
|
- static
|
||||||
|
- media
|
||||||
|
section-titles:
|
||||||
|
- ingredients
|
||||||
|
- allergy information
|
||||||
|
- allergens
|
||||||
|
- nutritional information
|
||||||
|
- nutrition
|
||||||
|
- storage
|
||||||
|
- directions
|
||||||
|
- preparation
|
||||||
|
- serving suggestions
|
||||||
|
- origin
|
||||||
|
- country of origin
|
||||||
|
- recycling
|
||||||
|
- general information
|
||||||
|
- additional information
|
||||||
|
- a note about prices
|
||||||
|
|
||||||
|
blacklist:
|
||||||
|
category:
|
||||||
|
- branded-goods/alcoholic-drinks
|
||||||
|
- branded-goods/beers
|
||||||
|
- branded-goods/wines
|
||||||
|
- branded-goods/ciders
|
||||||
|
product:
|
||||||
|
- list-price-suma-current-suma-price-list-each-bk012-2-html
|
||||||
|
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
|
||||||
|
product-details:
|
||||||
|
- General Information
|
||||||
|
- A Note About Prices
|
||||||
|
|
||||||
|
# SumUp payment settings (fill these in for live usage)
|
||||||
|
sumup:
|
||||||
|
merchant_code: "ME4J6100"
|
||||||
|
currency: "GBP"
|
||||||
|
# Name of the environment variable that holds your SumUp API key
|
||||||
|
api_key_env: "SUMUP_API_KEY"
|
||||||
|
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
|
||||||
|
checkout_reference_prefix: 'dev-'
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ fi
|
|||||||
# Run DB migrations only if RUN_MIGRATIONS=true (blog service only)
|
# Run DB migrations only if RUN_MIGRATIONS=true (blog service only)
|
||||||
if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then
|
if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then
|
||||||
echo "Running Alembic migrations..."
|
echo "Running Alembic migrations..."
|
||||||
cd shared_lib && alembic upgrade head && cd /app
|
(cd shared && alembic upgrade head)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clear Redis page cache on deploy
|
# Clear Redis page cache on deploy
|
||||||
|
|||||||
14
models/__init__.py
Normal file
14
models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
|
||||||
|
from .snippet import Snippet
|
||||||
|
from .tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
# Shared models — canonical definitions live in shared/models/
|
||||||
|
from shared.models.ghost_membership_entities import (
|
||||||
|
GhostLabel, UserLabel,
|
||||||
|
GhostNewsletter, UserNewsletter,
|
||||||
|
GhostTier, GhostSubscription,
|
||||||
|
)
|
||||||
|
from shared.models.menu_item import MenuItem
|
||||||
|
from shared.models.kv import KV
|
||||||
|
from shared.models.magic_link import MagicLink
|
||||||
|
from shared.models.user import User
|
||||||
3
models/ghost_content.py
Normal file
3
models/ghost_content.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from shared.models.ghost_content import ( # noqa: F401
|
||||||
|
Tag, Post, Author, PostAuthor, PostTag, PostLike,
|
||||||
|
)
|
||||||
12
models/ghost_membership_entities.py
Normal file
12
models/ghost_membership_entities.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.ghost_membership_entities import (
|
||||||
|
GhostLabel, UserLabel,
|
||||||
|
GhostNewsletter, UserNewsletter,
|
||||||
|
GhostTier, GhostSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GhostLabel", "UserLabel",
|
||||||
|
"GhostNewsletter", "UserNewsletter",
|
||||||
|
"GhostTier", "GhostSubscription",
|
||||||
|
]
|
||||||
4
models/kv.py
Normal file
4
models/kv.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.kv import KV
|
||||||
|
|
||||||
|
__all__ = ["KV"]
|
||||||
4
models/magic_link.py
Normal file
4
models/magic_link.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.magic_link import MagicLink
|
||||||
|
|
||||||
|
__all__ = ["MagicLink"]
|
||||||
4
models/menu_item.py
Normal file
4
models/menu_item.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.menu_item import MenuItem
|
||||||
|
|
||||||
|
__all__ = ["MenuItem"]
|
||||||
32
models/snippet.py
Normal file
32
models/snippet.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Snippet(Base):
|
||||||
|
__tablename__ = "snippets"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
|
||||||
|
Index("ix_snippets_visibility", "visibility"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
visibility: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="private", server_default="private",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
|
||||||
|
)
|
||||||
52
models/tag_group.py
Normal file
52
models/tag_group.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy import (
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroup(Base):
|
||||||
|
__tablename__ = "tag_groups"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
|
||||||
|
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
|
colour: Mapped[Optional[str]] = mapped_column(String(32))
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
tag_links: Mapped[List["TagGroupTag"]] = relationship(
|
||||||
|
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroupTag(Base):
|
||||||
|
__tablename__ = "tag_group_tags"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tag_group_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")
|
||||||
4
models/user.py
Normal file
4
models/user.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["User"]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Add the shared library submodule to the Python path
|
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
|
_project_root = os.path.dirname(_app_dir)
|
||||||
if _shared not in sys.path:
|
|
||||||
sys.path.insert(0, _shared)
|
for _p in (_project_root, _app_dir):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|||||||
28
services/__init__.py
Normal file
28
services/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Blog app service registration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def register_domain_services() -> None:
|
||||||
|
"""Register services for the blog app.
|
||||||
|
|
||||||
|
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
|
||||||
|
Standard deployment registers all 4 services as real DB impls
|
||||||
|
(shared DB). For composable deployments, swap non-owned services
|
||||||
|
with stubs from shared.services.stubs.
|
||||||
|
"""
|
||||||
|
from shared.services.registry import services
|
||||||
|
from shared.services.blog_impl import SqlBlogService
|
||||||
|
from shared.services.calendar_impl import SqlCalendarService
|
||||||
|
from shared.services.market_impl import SqlMarketService
|
||||||
|
from shared.services.cart_impl import SqlCartService
|
||||||
|
|
||||||
|
services.blog = SqlBlogService()
|
||||||
|
if not services.has("calendar"):
|
||||||
|
services.calendar = SqlCalendarService()
|
||||||
|
if not services.has("market"):
|
||||||
|
services.market = SqlMarketService()
|
||||||
|
if not services.has("cart"):
|
||||||
|
services.cart = SqlCartService()
|
||||||
|
if not services.has("federation"):
|
||||||
|
from shared.services.federation_impl import SqlFederationService
|
||||||
|
services.federation = SqlFederationService()
|
||||||
1
shared
Submodule
1
shared
Submodule
Submodule shared added at 9ab4b7b3fe
Submodule shared_lib deleted from 0c9b8d6aa2
33
templates/_email/magic_link.html
Normal file
33
templates/_email/magic_link.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
|
||||||
|
<tr><td>
|
||||||
|
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
|
||||||
|
Click the button below to sign in. This link will expire in 15 minutes.
|
||||||
|
</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
|
||||||
|
<a href="{{ link_url }}" target="_blank"
|
||||||
|
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</td></tr></table>
|
||||||
|
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
|
||||||
|
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#a8a29e;">
|
||||||
|
If you did not request this email, you can safely ignore it.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
templates/_email/magic_link.txt
Normal file
8
templates/_email/magic_link.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Click this link to sign in:
|
||||||
|
{{ link_url }}
|
||||||
|
|
||||||
|
This link will expire in 15 minutes.
|
||||||
|
|
||||||
|
If you did not request this, you can ignore this email.
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
|
||||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Account header #}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
|
|
||||||
{% if g.user %}
|
|
||||||
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
|
|
||||||
{% if g.user.name %}
|
|
||||||
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<form action="{{ url_for('auth.logout')|host }}" method="post">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-right-from-bracket text-xs"></i>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Labels #}
|
|
||||||
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
|
|
||||||
{% if labels %}
|
|
||||||
<div>
|
|
||||||
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{% for label in labels %}
|
|
||||||
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
|
|
||||||
{{ label.name }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
|
||||||
{% call links.link(url_for('auth.newsletters'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
|
||||||
newsletters
|
|
||||||
{% endcall %}
|
|
||||||
{% call links.link(cart_url('/orders/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
|
||||||
orders
|
|
||||||
{% endcall %}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
|
||||||
<button
|
|
||||||
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=un.newsletter_id) }}"
|
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
|
||||||
hx-target="#nl-{{ un.newsletter_id }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
|
|
||||||
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
|
|
||||||
role="switch"
|
|
||||||
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
|
|
||||||
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
|
||||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
|
|
||||||
|
|
||||||
{% if newsletter_list %}
|
|
||||||
<div class="divide-y divide-stone-100">
|
|
||||||
{% for item in newsletter_list %}
|
|
||||||
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
|
|
||||||
{% if item.newsletter.description %}
|
|
||||||
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 flex-shrink-0">
|
|
||||||
{% if item.un %}
|
|
||||||
{% with un=item.un %}
|
|
||||||
{% include "_types/auth/_newsletter_toggle.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% else %}
|
|
||||||
{# No subscription row yet — show an off toggle that will create one #}
|
|
||||||
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
|
||||||
<button
|
|
||||||
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=item.newsletter.id) }}"
|
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
|
||||||
hx-target="#nl-{{ item.newsletter.id }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
|
|
||||||
role="switch"
|
|
||||||
aria-checked="false"
|
|
||||||
>
|
|
||||||
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-stone-500">No newsletters available.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -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="{{ url_for('auth.login_form')|host }}"
|
|
||||||
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(url_for('auth.account'), hx_select_search ) %}
|
|
||||||
<i class="fa-solid fa-user"></i>
|
|
||||||
<div>account</div>
|
|
||||||
{% endcall %}
|
|
||||||
{% call links.desktop_nav() %}
|
|
||||||
{% include "_types/auth/_nav.html" %}
|
|
||||||
{% endcall %}
|
|
||||||
{% endcall %}
|
|
||||||
{% endmacro %}
|
|
||||||
@@ -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="{{ url_for('auth.start_login')|host }}"
|
|
||||||
class="mt-6 space-y-5"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value="{{ email or '' }}"
|
|
||||||
required
|
|
||||||
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
|
|
||||||
autocomplete="email"
|
|
||||||
inputmode="email"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
|
|
||||||
>
|
|
||||||
Send link
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -69,40 +69,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Associated Entries - Scrollable list #}
|
{# Card decorations — via fragments #}
|
||||||
{% if post.associated_entries %}
|
{% if card_widgets_html %}
|
||||||
<div class="mt-4 mb-2">
|
{% set _card_html = card_widgets_html.get(post.id|string, "") %}
|
||||||
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
|
{% if _card_html %}{{ _card_html | safe }}{% endif %}
|
||||||
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
|
|
||||||
<div class="flex gap-2 px-2">
|
|
||||||
{% for entry in post.associated_entries %}
|
|
||||||
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar.slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
|
||||||
<a
|
|
||||||
href="{{ events_url(_entry_path) }}"
|
|
||||||
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
|
|
||||||
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
|
|
||||||
<div class="text-xs text-stone-600">
|
|
||||||
{{ entry.start_at.strftime('%a, %b %d') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-stone-500">
|
|
||||||
{{ entry.start_at.strftime('%H:%M') }}
|
|
||||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include '_types/blog/_card/at_bar.html' %}
|
{% include '_types/blog/_card/at_bar.html' %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
{# Content type tabs: Posts | Pages #}
|
{# Content type tabs: Posts | Pages #}
|
||||||
<div class="flex justify-center gap-1 px-3 pt-3">
|
<div class="flex justify-center gap-1 px-3 pt-3">
|
||||||
{% set posts_href = (url_for('blog.home'))|host %}
|
{% set posts_href = (url_for('blog.index'))|host %}
|
||||||
{% set pages_href = (url_for('blog.home') ~ '?type=pages')|host %}
|
{% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %}
|
||||||
<a
|
<a
|
||||||
href="{{ posts_href }}"
|
href="{{ posts_href }}"
|
||||||
hx-get="{{ posts_href }}"
|
hx-get="{{ posts_href }}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% import '_types/browse/desktop/_filter/search.html' as s %}
|
{% from 'macros/search.html' import search_desktop %}
|
||||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
|
||||||
{% include '_types/blog/_action_buttons.html' %}
|
{% include '_types/blog/_action_buttons.html' %}
|
||||||
<div
|
<div
|
||||||
id="category-summary-desktop"
|
id="category-summary-desktop"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
The post "{{ slug }}" could not be found.
|
The post "{{ slug }}" could not be found.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('blog.home')|host }}"
|
href="{{ url_for('blog.index')|host }}"
|
||||||
hx-get="{{ url_for('blog.home')|host }}"
|
hx-get="{{ url_for('blog.index')|host }}"
|
||||||
hx-target="#main-panel"
|
hx-target="#main-panel"
|
||||||
hx-select="{{ hx_select }}"
|
hx-select="{{ hx_select }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
value=""
|
value=""
|
||||||
placeholder="Post title..."
|
placeholder="{{ 'Page title...' if is_page else 'Post title...' }}"
|
||||||
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
||||||
placeholder:text-stone-300 mb-[8px] leading-tight"
|
placeholder:text-stone-300 mb-[8px] leading-tight"
|
||||||
>
|
>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
||||||
hover:bg-stone-800 transition-colors cursor-pointer"
|
hover:bg-stone-800 transition-colors cursor-pointer"
|
||||||
>Create Post</button>
|
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
19
templates/_types/home/_oob_elements.html
Normal file
19
templates/_types/home/_oob_elements.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article class="relative">
|
||||||
|
<div class="blog-content p-2">
|
||||||
|
{% if post.html %}
|
||||||
|
{{post.html|safe}}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
14
templates/_types/home/index.html
Normal file
14
templates/_types/home/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
{% block meta %}
|
||||||
|
{% include '_types/post/_meta.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article class="relative">
|
||||||
|
<div class="blog-content p-2">
|
||||||
|
{% if post.html %}
|
||||||
|
{{post.html|safe}}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,21 +12,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Hidden field for selected post ID - outside form for JS access #}
|
{# Hidden field for selected post ID - outside form for JS access #}
|
||||||
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.post_id if menu_item else '' }}" />
|
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.container_id if menu_item else '' }}" />
|
||||||
|
|
||||||
{# Selected page display #}
|
{# Selected page display #}
|
||||||
{% if menu_item %}
|
{% if menu_item %}
|
||||||
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
|
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
|
||||||
{% if menu_item.post.feature_image %}
|
{% if menu_item.feature_image %}
|
||||||
<img src="{{ menu_item.post.feature_image }}"
|
<img src="{{ menu_item.feature_image }}"
|
||||||
alt="{{ menu_item.post.title }}"
|
alt="{{ menu_item.label }}"
|
||||||
class="w-10 h-10 rounded-full object-cover" />
|
class="w-10 h-10 rounded-full object-cover" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
|
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium">{{ menu_item.post.title }}</div>
|
<div class="font-medium">{{ menu_item.label }}</div>
|
||||||
<div class="text-xs text-stone-500">{{ menu_item.post.slug }}</div>
|
<div class="text-xs text-stone-500">{{ menu_item.slug }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Page image #}
|
{# Page image #}
|
||||||
{% if item.post.feature_image %}
|
{% if item.feature_image %}
|
||||||
<img src="{{ item.post.feature_image }}"
|
<img src="{{ item.feature_image }}"
|
||||||
alt="{{ item.post.title }}"
|
alt="{{ item.label }}"
|
||||||
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
|
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
|
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
{# Page title #}
|
{# Page title #}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium truncate">{{ item.post.title }}</div>
|
<div class="font-medium truncate">{{ item.label }}</div>
|
||||||
<div class="text-xs text-stone-500 truncate">{{ item.post.slug }}</div>
|
<div class="text-xs text-stone-500 truncate">{{ item.slug }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Sort order #}
|
{# Sort order #}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
data-confirm
|
data-confirm
|
||||||
data-confirm-title="Delete menu item?"
|
data-confirm-title="Delete menu item?"
|
||||||
data-confirm-text="Remove {{ item.post.title }} from the menu?"
|
data-confirm-text="Remove {{ item.label }} from the menu?"
|
||||||
data-confirm-icon="warning"
|
data-confirm-icon="warning"
|
||||||
data-confirm-confirm-text="Yes, delete"
|
data-confirm-confirm-text="Yes, delete"
|
||||||
data-confirm-cancel-text="Cancel"
|
data-confirm-cancel-text="Cancel"
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
{% set _app_slugs = {'cart': cart_url('/')} %}
|
{% set _app_slugs = {'cart': cart_url('/')} %}
|
||||||
|
{% set _first_seg = request.path.strip('/').split('/')[0] %}
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
<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"
|
id="menu-items-nav-wrapper"
|
||||||
hx-swap-oob="outerHTML">
|
hx-swap-oob="outerHTML">
|
||||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||||
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
|
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
|
||||||
{% set _href = _app_slugs.get(item.post.slug, coop_url('/' + item.post.slug + '/')) %}
|
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
|
||||||
<a
|
<a
|
||||||
href="{{ _href }}"
|
href="{{ _href }}"
|
||||||
{% if item.post.slug not in _app_slugs %}
|
{% if item.slug not in _app_slugs %}
|
||||||
hx-get="/{{ item.post.slug }}/"
|
hx-get="/{{ item.slug }}/"
|
||||||
hx-target="#main-panel"
|
hx-target="#main-panel"
|
||||||
hx-select="{{ hx_select_search }}"
|
hx-select="{{ hx_select_search }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
|
||||||
class="{{styles.nav_button}}"
|
class="{{styles.nav_button}}"
|
||||||
>
|
>
|
||||||
{% if item.post.feature_image %}
|
{% if item.feature_image %}
|
||||||
<img src="{{ item.post.feature_image }}"
|
<img src="{{ item.feature_image }}"
|
||||||
alt="{{ item.post.title }}"
|
alt="{{ item.label }}"
|
||||||
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>{{ item.post.title }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
|
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
|
||||||
|
|
||||||
{% for entry in entry_list %}
|
{% for entry in entry_list %}
|
||||||
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar.slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||||
<a
|
<a
|
||||||
href="{{ events_url(_entry_path) }}"
|
href="{{ events_url(_entry_path) }}"
|
||||||
class="{{styles.nav_button_less_pad}}"
|
class="{{styles.nav_button_less_pad}}"
|
||||||
>
|
>
|
||||||
{% if entry.calendar.post.feature_image %}
|
{% if post.feature_image %}
|
||||||
<img src="{{ entry.calendar.post.feature_image }}"
|
<img src="{{ post.feature_image }}"
|
||||||
alt="{{ entry.calendar.post.title }}"
|
alt="{{ post.title }}"
|
||||||
class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
{# Main panel fragment for HTMX navigation - post article content #}
|
{# Main panel fragment for HTMX navigation - post/page article content #}
|
||||||
<article class="relative">
|
<article class="relative">
|
||||||
{# ❤️ like button - always visible in top right of article #}
|
{# Draft indicator + edit link (shown for both posts and pages) #}
|
||||||
{% if g.user %}
|
|
||||||
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
|
|
||||||
{% set slug = post.slug %}
|
|
||||||
{% set liked = post.is_liked or False %}
|
|
||||||
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
|
|
||||||
{% set item_type = 'post' %}
|
|
||||||
{% include "_types/browse/like/button.html" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Draft indicator + edit link #}
|
|
||||||
{% if post.status == "draft" %}
|
{% if post.status == "draft" %}
|
||||||
<div class="flex items-center justify-center gap-2 mb-3">
|
<div class="flex items-center justify-center gap-2 mb-3">
|
||||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
|
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
|
||||||
@@ -36,6 +25,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not post.is_page %}
|
||||||
|
{# ── Blog post chrome: like button, excerpt, tags/authors ── #}
|
||||||
|
{% if g.user %}
|
||||||
|
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
|
||||||
|
{% set slug = post.slug %}
|
||||||
|
{% set liked = post.is_liked or False %}
|
||||||
|
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
|
||||||
|
{% set item_type = 'post' %}
|
||||||
|
{% include "_types/browse/like/button.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if post.custom_excerpt %}
|
{% if post.custom_excerpt %}
|
||||||
<div class="w-full text-center italic text-3xl p-2">
|
<div class="w-full text-center italic text-3xl p-2">
|
||||||
{{post.custom_excerpt|safe}}
|
{{post.custom_excerpt|safe}}
|
||||||
@@ -44,6 +45,8 @@
|
|||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
{% include '_types/blog/_card/at_bar.html' %}
|
{% include '_types/blog/_card/at_bar.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if post.feature_image %}
|
{% if post.feature_image %}
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #}
|
{# Widget-driven container nav — entries, calendars, markets #}
|
||||||
{% if (associated_entries and associated_entries.entries) or calendars or markets %}
|
{% if container_nav_widgets %}
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
<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">
|
id="entries-calendars-nav-wrapper">
|
||||||
{% include '_types/post/admin/_nav_entries.html' %}
|
{% include '_types/post/admin/_nav_entries.html' %}
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
{# Admin link #}
|
{# Admin link #}
|
||||||
{% if post and has_access('blog.post.admin.admin') %}
|
{% if post and has_access('blog.post.admin.admin') %}
|
||||||
{% import 'macros/links.html' as links %}
|
|
||||||
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
{# Entries for this day #}
|
{# Entries for this day #}
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{% for e in month_entries %}
|
{% for e in month_entries %}
|
||||||
{% if e.start_at.date() == day.date and e.deleted_at is none %}
|
{% if e.start_at.date() == day.date %}
|
||||||
{% if e.id in associated_entry_ids %}
|
{% if e.id in associated_entry_ids %}
|
||||||
{# Associated entry - show with delete button #}
|
{# Associated entry - show with delete button #}
|
||||||
<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">
|
<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">
|
||||||
|
|||||||
50
templates/_types/post/admin/_nav_entries.html
Normal file
50
templates/_types/post/admin/_nav_entries.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
{# Left scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
_="on click
|
||||||
|
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
|
||||||
|
<i class="fa fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Widget-driven nav items container #}
|
||||||
|
<div id="associated-items-container"
|
||||||
|
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||||
|
style="scroll-behavior: smooth;"
|
||||||
|
_="on load or scroll
|
||||||
|
-- Show arrows if content overflows (desktop only)
|
||||||
|
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||||
|
remove .hidden from .entries-nav-arrow
|
||||||
|
add .flex to .entries-nav-arrow
|
||||||
|
else
|
||||||
|
add .hidden to .entries-nav-arrow
|
||||||
|
remove .flex from .entries-nav-arrow
|
||||||
|
end">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-1">
|
||||||
|
{% for wdata in container_nav_widgets %}
|
||||||
|
{% with ctx=wdata.ctx %}
|
||||||
|
{% include wdata.widget.template with context %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{# Right scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
_="on click
|
||||||
|
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
|
||||||
|
<i class="fa fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
@@ -6,7 +6,73 @@
|
|||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
<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"
|
id="entries-calendars-nav-wrapper"
|
||||||
hx-swap-oob="true">
|
hx-swap-oob="true">
|
||||||
{% include '_types/post/admin/_nav_entries.html' %}
|
{# Left scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
_="on click
|
||||||
|
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
|
||||||
|
<i class="fa fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="associated-items-container"
|
||||||
|
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||||
|
style="scroll-behavior: smooth;"
|
||||||
|
_="on load or scroll
|
||||||
|
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||||
|
remove .hidden from .entries-nav-arrow
|
||||||
|
add .flex to .entries-nav-arrow
|
||||||
|
else
|
||||||
|
add .hidden to .entries-nav-arrow
|
||||||
|
remove .flex from .entries-nav-arrow
|
||||||
|
end">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-1">
|
||||||
|
{# Calendar entries #}
|
||||||
|
{% if associated_entries and associated_entries.entries %}
|
||||||
|
{% for entry in associated_entries.entries %}
|
||||||
|
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||||
|
<a
|
||||||
|
href="{{ events_url(_entry_path) }}"
|
||||||
|
class="{{styles.nav_button_less_pad}}">
|
||||||
|
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||||
|
<div class="text-xs text-stone-600 truncate">
|
||||||
|
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||||
|
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{# Calendar links #}
|
||||||
|
{% if calendars %}
|
||||||
|
{% for calendar in calendars %}
|
||||||
|
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
|
||||||
|
<a
|
||||||
|
href="{{ local_href }}"
|
||||||
|
class="{{styles.nav_button_less_pad}}">
|
||||||
|
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||||
|
<div>{{calendar.name}}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{# Right scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
_="on click
|
||||||
|
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
|
||||||
|
<i class="fa fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Empty placeholder to remove nav items when all are disassociated/deleted #}
|
{# Empty placeholder to remove nav items when all are disassociated/deleted #}
|
||||||
|
|||||||
137
templates/_types/post_data/_main_panel.html
Normal file
137
templates/_types/post_data/_main_panel.html
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{% macro render_scalar_table(obj) -%}
|
||||||
|
<div class="w-full overflow-x-auto sm:overflow-visible">
|
||||||
|
<table class="w-full table-fixed text-sm border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
|
||||||
|
<thead class="bg-neutral-50/70 dark:bg-neutral-900/60">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for col in obj.__mapper__.columns %}
|
||||||
|
{% set key = col.key %}
|
||||||
|
{% set val = obj|attr(key) %}
|
||||||
|
{% if key != "_sa_instance_state" %}
|
||||||
|
<tr class="border-t border-neutral-200 dark:border-neutral-800 align-top">
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap text-neutral-600 dark:text-neutral-400 align-top">{{ key }}</td>
|
||||||
|
<td class="px-3 py-2 align-top">
|
||||||
|
{% if val is none %}
|
||||||
|
<span class="text-neutral-400">—</span>
|
||||||
|
{% elif val.__class__.__name__ in ["datetime", "date"] and val.isoformat is defined %}
|
||||||
|
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ val.isoformat() }}</code></pre>
|
||||||
|
{% elif val is string %}
|
||||||
|
<pre class="whitespace-pre-wrap break-words break-all text-xs">{{ val }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ val }}</code></pre>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro render_model(obj, depth=0, max_depth=2) -%}
|
||||||
|
{% if obj is none %}
|
||||||
|
<span class="text-neutral-400">—</span>
|
||||||
|
{% else %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{{ render_scalar_table(obj) }}
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for rel in obj.__mapper__.relationships %}
|
||||||
|
{% set rel_name = rel.key %}
|
||||||
|
{% set loaded = rel.key in obj.__dict__ %}
|
||||||
|
{% if loaded %}
|
||||||
|
{% set value = obj|attr(rel_name) %}
|
||||||
|
{% else %}
|
||||||
|
{% set value = none %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div class="px-3 py-2 bg-neutral-50/70 dark:bg-neutral-900/60 text-sm font-medium">
|
||||||
|
Relationship: <span class="font-semibold">{{ rel_name }}</span>
|
||||||
|
<span class="ml-2 text-xs text-neutral-500">
|
||||||
|
{{ 'many' if rel.uselist else 'one' }} → {{ rel.mapper.class_.__name__ }}
|
||||||
|
{% if not loaded %} • <em>not loaded</em>{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 text-sm">
|
||||||
|
{% if value is none %}
|
||||||
|
<span class="text-neutral-400">—</span>
|
||||||
|
|
||||||
|
{% elif rel.uselist %}
|
||||||
|
{% set items = value or [] %}
|
||||||
|
<div class="text-neutral-500 mb-2">{{ items|length }} item{{ '' if items|length == 1 else 's' }}</div>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="w-full overflow-x-auto sm:overflow-visible">
|
||||||
|
<table class="w-full table-fixed text-sm border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
|
||||||
|
<thead class="bg-neutral-50/70 dark:bg-neutral-900/60">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1 text-left w-10">#</th>
|
||||||
|
<th class="px-2 py-1 text-left">Summary</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr class="border-t border-neutral-200 dark:border-neutral-800 align-top">
|
||||||
|
<td class="px-2 py-1 whitespace-nowrap align-top">{{ loop.index }}</td>
|
||||||
|
<td class="px-2 py-1 align-top">
|
||||||
|
{% set ident = [] %}
|
||||||
|
{% for k in ['id','ghost_id','uuid','slug','name','title'] if k in it.__mapper__.c %}
|
||||||
|
{% set v = (it|attr(k))|default('', true) %}
|
||||||
|
{% do ident.append(k ~ '=' ~ v) %}
|
||||||
|
{% endfor %}
|
||||||
|
<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{{ (ident|join(' • ')) or it|string }}</code></pre>
|
||||||
|
|
||||||
|
{% if depth < max_depth %}
|
||||||
|
<div class="mt-2 pl-3 border-l border-neutral-200 dark:border-neutral-800">
|
||||||
|
{{ render_model(it, depth+1, max_depth) }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-1 text-xs text-neutral-500">…max depth reached…</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% set child = value %}
|
||||||
|
{% set ident = [] %}
|
||||||
|
{% for k in ['id','ghost_id','uuid','slug','name','title'] if k in child.__mapper__.c %}
|
||||||
|
{% set v = (child|attr(k))|default('', true) %}
|
||||||
|
{% do ident.append(k ~ '=' ~ v) %}
|
||||||
|
{% endfor %}
|
||||||
|
<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{{ (ident|join(' • ')) or child|string }}</code></pre>
|
||||||
|
|
||||||
|
{% if depth < max_depth %}
|
||||||
|
<div class="pl-3 border-l border-neutral-200 dark:border-neutral-800">
|
||||||
|
{{ render_model(child, depth+1, max_depth) }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-xs text-neutral-500">…max depth reached…</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
<div class="px-4 py-8">
|
||||||
|
<div class="mb-6 text-sm text-neutral-500">
|
||||||
|
Model: <code>Post</code> • Table: <code>{{ original_post.__tablename__ }}</code>
|
||||||
|
</div>
|
||||||
|
{{ render_model(original_post, 0, 2) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
2
templates/_types/post_data/_nav.html
Normal file
2
templates/_types/post_data/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
@@ -5,25 +5,24 @@
|
|||||||
{# Import shared OOB macros #}
|
{# Import shared OOB macros #}
|
||||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
|
||||||
|
|
||||||
{% block oobs %}
|
{% block oobs %}
|
||||||
|
|
||||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
|
{{oob_header('post-admin-header-child', 'post_data-header-child', '_types/post_data/header/_header.html')}}
|
||||||
|
|
||||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
{% from '_types/post/admin/header/_header.html' import header_row with context %}
|
||||||
{{ header_row(oob=True) }}
|
{{ header_row(oob=True) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block mobile_menu %}
|
{% block mobile_menu %}
|
||||||
{% include '_types/auth/_nav.html' %}
|
{% include '_types/post_data/_nav.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include oob.main %}
|
{% include "_types/post_data/_main_panel.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
15
templates/_types/post_data/header/_header.html
Normal file
15
templates/_types/post_data/header/_header.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='post_data-row', oob=oob) %}
|
||||||
|
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
|
||||||
|
<i class="fa fa-database" aria-hidden="true"></i>
|
||||||
|
<div>data</div>
|
||||||
|
</a>
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{#% include '_types/post_data/_nav.html' %#}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
24
templates/_types/post_data/index.html
Normal file
24
templates/_types/post_data/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends '_types/post/admin/index.html' %}
|
||||||
|
|
||||||
|
{% block ___app_title %}
|
||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% call links.menu_row() %}
|
||||||
|
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search) %}
|
||||||
|
<i class="fa fa-database" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
data
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% include '_types/post_data/_nav.html' %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include '_types/post_data/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/post_data/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
352
templates/_types/post_edit/_main_panel.html
Normal file
352
templates/_types/post_edit/_main_panel.html
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
{# ── Error banner ── #}
|
||||||
|
{% if save_error %}
|
||||||
|
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
|
||||||
|
<strong>Save failed:</strong> {{ save_error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form id="post-edit-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="updated_at" value="{{ ghost_post.updated_at if ghost_post else '' }}">
|
||||||
|
<input type="hidden" id="lexical-json-input" name="lexical" value="">
|
||||||
|
<input type="hidden" id="feature-image-input" name="feature_image" value="{{ ghost_post.feature_image or '' if ghost_post else '' }}">
|
||||||
|
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{{ ghost_post.feature_image_caption or '' if ghost_post else '' }}">
|
||||||
|
|
||||||
|
{# ── Feature image ── #}
|
||||||
|
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
|
||||||
|
{# Empty state: add link #}
|
||||||
|
<div
|
||||||
|
id="feature-image-empty"
|
||||||
|
class="{{ 'hidden' if ghost_post and ghost_post.feature_image else '' }}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="feature-image-add-btn"
|
||||||
|
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||||
|
>+ Add feature image</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Filled state: image preview + controls #}
|
||||||
|
<div
|
||||||
|
id="feature-image-filled"
|
||||||
|
class="relative {{ '' if ghost_post and ghost_post.feature_image else 'hidden' }}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
id="feature-image-preview"
|
||||||
|
src="{{ ghost_post.feature_image or '' if ghost_post else '' }}"
|
||||||
|
alt=""
|
||||||
|
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
|
||||||
|
>
|
||||||
|
{# Delete button (top-right, visible on hover) #}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="feature-image-delete-btn"
|
||||||
|
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
|
||||||
|
flex items-center justify-center opacity-0 group-hover:opacity-100
|
||||||
|
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||||
|
title="Remove feature image"
|
||||||
|
><i class="fa-solid fa-trash-can"></i></button>
|
||||||
|
|
||||||
|
{# Caption input #}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="feature-image-caption"
|
||||||
|
value="{{ ghost_post.feature_image_caption or '' if ghost_post else '' }}"
|
||||||
|
placeholder="Add a caption..."
|
||||||
|
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
|
||||||
|
outline-none placeholder:text-stone-300 focus:text-stone-700"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Upload spinner overlay #}
|
||||||
|
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Hidden file input #}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="feature-image-file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Title ── #}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value="{{ ghost_post.title if ghost_post else '' }}"
|
||||||
|
placeholder="{{ 'Page title...' if post and post.is_page else 'Post title...' }}"
|
||||||
|
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
||||||
|
placeholder:text-stone-300 mb-[8px] leading-tight"
|
||||||
|
>
|
||||||
|
|
||||||
|
{# ── Excerpt ── #}
|
||||||
|
<textarea
|
||||||
|
name="custom_excerpt"
|
||||||
|
rows="1"
|
||||||
|
placeholder="Add an excerpt..."
|
||||||
|
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
|
||||||
|
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
|
||||||
|
>{{ ghost_post.custom_excerpt or '' if ghost_post else '' }}</textarea>
|
||||||
|
|
||||||
|
{# ── Editor mount point ── #}
|
||||||
|
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
|
||||||
|
|
||||||
|
{# ── Initial Lexical JSON from Ghost ── #}
|
||||||
|
<script id="lexical-initial-data" type="application/json">
|
||||||
|
{{ (ghost_post.lexical or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}')|safe }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# ── Status + Publish mode + Save footer ── #}
|
||||||
|
{% set already_emailed = ghost_post and ghost_post.email and ghost_post.email.status %}
|
||||||
|
<div class="flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
|
||||||
|
<select
|
||||||
|
id="status-select"
|
||||||
|
name="status"
|
||||||
|
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||||
|
>
|
||||||
|
<option value="draft" {{ 'selected' if not ghost_post or ghost_post.status == 'draft' else '' }}>Draft</option>
|
||||||
|
<option value="published" {{ 'selected' if ghost_post and ghost_post.status == 'published' else '' }}>Published</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{# Publish mode — only relevant when publishing #}
|
||||||
|
<select
|
||||||
|
id="publish-mode-select"
|
||||||
|
name="publish_mode"
|
||||||
|
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600
|
||||||
|
{{ 'hidden' if not ghost_post or ghost_post.status != 'published' else '' }}
|
||||||
|
{{ 'opacity-50 pointer-events-none' if already_emailed else '' }}"
|
||||||
|
{{ 'disabled' if already_emailed else '' }}
|
||||||
|
>
|
||||||
|
<option value="web" selected>Web only</option>
|
||||||
|
<option value="email">Email only</option>
|
||||||
|
<option value="both">Web + Email</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{# Newsletter picker — only when email is involved #}
|
||||||
|
<select
|
||||||
|
id="newsletter-select"
|
||||||
|
name="newsletter_slug"
|
||||||
|
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600 hidden"
|
||||||
|
{{ 'disabled' if already_emailed else '' }}
|
||||||
|
>
|
||||||
|
<option value="">Select newsletter…</option>
|
||||||
|
{% for nl in newsletters|default([]) %}
|
||||||
|
<option value="{{ nl.slug }}">{{ nl.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
||||||
|
hover:bg-stone-800 transition-colors cursor-pointer"
|
||||||
|
>Save</button>
|
||||||
|
|
||||||
|
{% if save_success %}
|
||||||
|
<span class="text-[14px] text-green-600">Saved.</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if request.args.get('publish_requested') %}
|
||||||
|
<span class="text-[14px] text-blue-600">Publish requested — an admin will review.</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if post and post.publish_requested %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if already_emailed %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
|
||||||
|
Emailed{% if ghost_post.newsletter %} to {{ ghost_post.newsletter.name }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Publish-mode show/hide logic ── #}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var statusSel = document.getElementById('status-select');
|
||||||
|
var modeSel = document.getElementById('publish-mode-select');
|
||||||
|
var nlSel = document.getElementById('newsletter-select');
|
||||||
|
var alreadyEmailed = {{ 'true' if already_emailed else 'false' }};
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var isPublished = statusSel.value === 'published';
|
||||||
|
// Show publish mode only when status is published and not already emailed
|
||||||
|
if (isPublished && !alreadyEmailed) {
|
||||||
|
modeSel.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
modeSel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
// Show newsletter picker when email is involved
|
||||||
|
var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');
|
||||||
|
if (needsEmail) {
|
||||||
|
nlSel.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
nlSel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusSel.addEventListener('change', sync);
|
||||||
|
modeSel.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Koenig editor assets ── #}
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
|
||||||
|
<style>
|
||||||
|
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
|
||||||
|
We apply that via JS (see init() below) so the header bars render at
|
||||||
|
normal size on first paint, then shrink only the editor area.
|
||||||
|
A beforeSwap listener restores the default when navigating away. */
|
||||||
|
#lexical-editor { display: flow-root; }
|
||||||
|
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
|
||||||
|
#lexical-editor [data-kg-card="html"] * { float: none !important; }
|
||||||
|
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
|
||||||
|
</style>
|
||||||
|
<script src="{{ asset_url('scripts/editor.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
|
||||||
|
restore default when navigating away via HTMX ── */
|
||||||
|
function applyEditorFontSize() {
|
||||||
|
document.documentElement.style.fontSize = '62.5%';
|
||||||
|
document.body.style.fontSize = '1.6rem';
|
||||||
|
}
|
||||||
|
function restoreDefaultFontSize() {
|
||||||
|
document.documentElement.style.fontSize = '';
|
||||||
|
document.body.style.fontSize = '';
|
||||||
|
}
|
||||||
|
applyEditorFontSize();
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
|
||||||
|
if (e.detail.target && e.detail.target.id === 'main-panel') {
|
||||||
|
restoreDefaultFontSize();
|
||||||
|
document.body.removeEventListener('htmx:beforeSwap', cleanup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
|
||||||
|
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
|
||||||
|
var uploadUrls = {
|
||||||
|
image: uploadUrl,
|
||||||
|
media: '{{ url_for("blog.editor_api.upload_media") }}',
|
||||||
|
file: '{{ url_for("blog.editor_api.upload_file") }}',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Feature image upload / delete / replace ── */
|
||||||
|
var fileInput = document.getElementById('feature-image-file');
|
||||||
|
var addBtn = document.getElementById('feature-image-add-btn');
|
||||||
|
var deleteBtn = document.getElementById('feature-image-delete-btn');
|
||||||
|
var preview = document.getElementById('feature-image-preview');
|
||||||
|
var emptyState = document.getElementById('feature-image-empty');
|
||||||
|
var filledState = document.getElementById('feature-image-filled');
|
||||||
|
var hiddenUrl = document.getElementById('feature-image-input');
|
||||||
|
var hiddenCaption = document.getElementById('feature-image-caption-input');
|
||||||
|
var captionInput = document.getElementById('feature-image-caption');
|
||||||
|
var uploading = document.getElementById('feature-image-uploading');
|
||||||
|
|
||||||
|
function showFilled(url) {
|
||||||
|
preview.src = url;
|
||||||
|
hiddenUrl.value = url;
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
filledState.classList.remove('hidden');
|
||||||
|
uploading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmpty() {
|
||||||
|
preview.src = '';
|
||||||
|
hiddenUrl.value = '';
|
||||||
|
hiddenCaption.value = '';
|
||||||
|
captionInput.value = '';
|
||||||
|
emptyState.classList.remove('hidden');
|
||||||
|
filledState.classList.add('hidden');
|
||||||
|
uploading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
uploading.classList.remove('hidden');
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
headers: { 'X-CSRFToken': csrfToken },
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
var url = data.images && data.images[0] && data.images[0].url;
|
||||||
|
if (url) showFilled(url);
|
||||||
|
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
showEmpty();
|
||||||
|
alert(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', function() { fileInput.click(); });
|
||||||
|
preview.addEventListener('click', function() { fileInput.click(); });
|
||||||
|
deleteBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showEmpty();
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
if (fileInput.files && fileInput.files[0]) {
|
||||||
|
uploadFile(fileInput.files[0]);
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
captionInput.addEventListener('input', function() {
|
||||||
|
hiddenCaption.value = captionInput.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Auto-resize excerpt textarea ── */
|
||||||
|
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
|
||||||
|
function autoResize() {
|
||||||
|
excerpt.style.height = 'auto';
|
||||||
|
excerpt.style.height = excerpt.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
excerpt.addEventListener('input', autoResize);
|
||||||
|
autoResize();
|
||||||
|
|
||||||
|
/* ── Mount Koenig editor ── */
|
||||||
|
var dataEl = document.getElementById('lexical-initial-data');
|
||||||
|
var initialJson = dataEl ? dataEl.textContent.trim() : null;
|
||||||
|
if (initialJson) {
|
||||||
|
var hidden = document.getElementById('lexical-json-input');
|
||||||
|
if (hidden) hidden.value = initialJson;
|
||||||
|
}
|
||||||
|
window.mountEditor('lexical-editor', {
|
||||||
|
initialJson: initialJson,
|
||||||
|
csrfToken: csrfToken,
|
||||||
|
uploadUrls: uploadUrls,
|
||||||
|
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
|
||||||
|
unsplashApiKey: '{{ unsplash_api_key or "" }}',
|
||||||
|
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Ctrl-S / Cmd-S to save ── */
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('post-edit-form').requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* editor.js loads synchronously on full page loads but asynchronously
|
||||||
|
when HTMX swaps the content in, so wait for it if needed. */
|
||||||
|
if (typeof window.mountEditor === 'function') {
|
||||||
|
init();
|
||||||
|
} else {
|
||||||
|
var _t = setInterval(function() {
|
||||||
|
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
5
templates/_types/post_edit/_nav.html
Normal file
5
templates/_types/post_edit/_nav.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||||
|
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||||
|
settings
|
||||||
|
{% endcall %}
|
||||||
19
templates/_types/post_edit/_oob_elements.html
Normal file
19
templates/_types/post_edit/_oob_elements.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('post-admin-header-child', 'post_edit-header-child', '_types/post_edit/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/post/admin/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/post_edit/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/post_edit/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
14
templates/_types/post_edit/header/_header.html
Normal file
14
templates/_types/post_edit/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='post_edit-row', oob=oob) %}
|
||||||
|
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search) %}
|
||||||
|
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
edit
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% include '_types/post_edit/_nav.html' %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
17
templates/_types/post_edit/index.html
Normal file
17
templates/_types/post_edit/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends '_types/post/admin/index.html' %}
|
||||||
|
|
||||||
|
{% block post_admin_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('post-admin-header-child', '_types/post_edit/header/_header.html') %}
|
||||||
|
{% block post_edit_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include '_types/post_edit/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/post_edit/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
198
templates/_types/post_settings/_main_panel.html
Normal file
198
templates/_types/post_settings/_main_panel.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{# ── Post/Page Settings Form ── #}
|
||||||
|
{% set gp = ghost_post or {} %}
|
||||||
|
{% set _is_page = post.is_page if post else False %}
|
||||||
|
|
||||||
|
{% macro field_label(text, field_for=None) %}
|
||||||
|
<label {% if field_for %}for="{{ field_for }}"{% endif %}
|
||||||
|
class="block text-[13px] font-medium text-stone-500 mb-[4px]">{{ text }}</label>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro text_input(name, value='', placeholder='', type='text', maxlength=None) %}
|
||||||
|
<input
|
||||||
|
type="{{ type }}"
|
||||||
|
name="{{ name }}"
|
||||||
|
id="settings-{{ name }}"
|
||||||
|
value="{{ value }}"
|
||||||
|
placeholder="{{ placeholder }}"
|
||||||
|
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
|
||||||
|
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||||
|
bg-white text-stone-700 placeholder:text-stone-300
|
||||||
|
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||||
|
>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro textarea_input(name, value='', placeholder='', rows=3, maxlength=None) %}
|
||||||
|
<textarea
|
||||||
|
name="{{ name }}"
|
||||||
|
id="settings-{{ name }}"
|
||||||
|
rows="{{ rows }}"
|
||||||
|
placeholder="{{ placeholder }}"
|
||||||
|
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
|
||||||
|
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||||
|
bg-white text-stone-700 placeholder:text-stone-300 resize-y
|
||||||
|
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||||
|
>{{ value }}</textarea>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro checkbox_input(name, checked=False, label='') %}
|
||||||
|
<label class="inline-flex items-center gap-[8px] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="{{ name }}"
|
||||||
|
id="settings-{{ name }}"
|
||||||
|
{{ 'checked' if checked else '' }}
|
||||||
|
class="rounded border-stone-300 text-stone-600 focus:ring-stone-300"
|
||||||
|
>
|
||||||
|
<span class="text-[14px] text-stone-600">{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro section(title, open=False) %}
|
||||||
|
<details class="border border-stone-200 rounded-[8px] overflow-hidden" {{ 'open' if open else '' }}>
|
||||||
|
<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer
|
||||||
|
select-none hover:bg-stone-100 transition-colors">
|
||||||
|
{{ title }}
|
||||||
|
</summary>
|
||||||
|
<div class="px-[16px] py-[12px] space-y-[12px]">
|
||||||
|
{{ caller() }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="updated_at" value="{{ gp.updated_at or '' }}">
|
||||||
|
|
||||||
|
<div class="space-y-[12px] mt-[16px]">
|
||||||
|
|
||||||
|
{# ── General ── #}
|
||||||
|
{% call section('General', open=True) %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('Slug', 'settings-slug') }}
|
||||||
|
{{ text_input('slug', gp.slug or '', 'page-slug' if _is_page else 'post-slug') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('Published at', 'settings-published_at') }}
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="published_at"
|
||||||
|
id="settings-published_at"
|
||||||
|
value="{{ gp.published_at[:16] if gp.published_at else '' }}"
|
||||||
|
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||||
|
bg-white text-stone-700
|
||||||
|
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ checkbox_input('featured', gp.featured, 'Featured page' if _is_page else 'Featured post') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('Visibility', 'settings-visibility') }}
|
||||||
|
<select
|
||||||
|
name="visibility"
|
||||||
|
id="settings-visibility"
|
||||||
|
class="w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px]
|
||||||
|
bg-white text-stone-700
|
||||||
|
focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300"
|
||||||
|
>
|
||||||
|
<option value="public" {{ 'selected' if (gp.visibility or 'public') == 'public' }}>Public</option>
|
||||||
|
<option value="members" {{ 'selected' if gp.visibility == 'members' }}>Members</option>
|
||||||
|
<option value="paid" {{ 'selected' if gp.visibility == 'paid' }}>Paid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ checkbox_input('email_only', gp.email_only, 'Email only') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── Tags ── #}
|
||||||
|
{% call section('Tags') %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('Tags (comma-separated)', 'settings-tags') }}
|
||||||
|
{% set tag_names = gp.tags|map(attribute='name')|list|join(', ') if gp.tags else '' %}
|
||||||
|
{{ text_input('tags', tag_names, 'news, updates, featured') }}
|
||||||
|
<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── Feature Image ── #}
|
||||||
|
{% call section('Feature Image') %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('Alt text', 'settings-feature_image_alt') }}
|
||||||
|
{{ text_input('feature_image_alt', gp.feature_image_alt or '', 'Describe the feature image') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── SEO / Meta ── #}
|
||||||
|
{% call section('SEO / Meta') %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('Meta title', 'settings-meta_title') }}
|
||||||
|
{{ text_input('meta_title', gp.meta_title or '', 'SEO title', maxlength=300) }}
|
||||||
|
<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('Meta description', 'settings-meta_description') }}
|
||||||
|
{{ textarea_input('meta_description', gp.meta_description or '', 'SEO description', rows=2, maxlength=500) }}
|
||||||
|
<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('Canonical URL', 'settings-canonical_url') }}
|
||||||
|
{{ text_input('canonical_url', gp.canonical_url or '', 'https://example.com/original-post', type='url') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── Facebook / OpenGraph ── #}
|
||||||
|
{% call section('Facebook / OpenGraph') %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('OG title', 'settings-og_title') }}
|
||||||
|
{{ text_input('og_title', gp.og_title or '') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('OG description', 'settings-og_description') }}
|
||||||
|
{{ textarea_input('og_description', gp.og_description or '', rows=2) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('OG image URL', 'settings-og_image') }}
|
||||||
|
{{ text_input('og_image', gp.og_image or '', 'https://...', type='url') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── X / Twitter ── #}
|
||||||
|
{% call section('X / Twitter') %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('Twitter title', 'settings-twitter_title') }}
|
||||||
|
{{ text_input('twitter_title', gp.twitter_title or '') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('Twitter description', 'settings-twitter_description') }}
|
||||||
|
{{ textarea_input('twitter_description', gp.twitter_description or '', rows=2) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field_label('Twitter image URL', 'settings-twitter_image') }}
|
||||||
|
{{ text_input('twitter_image', gp.twitter_image or '', 'https://...', type='url') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── Advanced ── #}
|
||||||
|
{% call section('Advanced') %}
|
||||||
|
<div>
|
||||||
|
{{ field_label('Custom template', 'settings-custom_template') }}
|
||||||
|
{{ text_input('custom_template', gp.custom_template or '', 'custom-page.hbs' if _is_page else 'custom-post.hbs') }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Save footer ── #}
|
||||||
|
<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
||||||
|
hover:bg-stone-800 transition-colors cursor-pointer"
|
||||||
|
>Save settings</button>
|
||||||
|
|
||||||
|
{% if save_success %}
|
||||||
|
<span class="text-[14px] text-green-600">Saved.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
5
templates/_types/post_settings/_nav.html
Normal file
5
templates/_types/post_settings/_nav.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||||
|
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
|
||||||
|
edit
|
||||||
|
{% endcall %}
|
||||||
19
templates/_types/post_settings/_oob_elements.html
Normal file
19
templates/_types/post_settings/_oob_elements.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('post-admin-header-child', 'post_settings-header-child', '_types/post_settings/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/post/admin/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/post_settings/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/post_settings/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
14
templates/_types/post_settings/header/_header.html
Normal file
14
templates/_types/post_settings/header/_header.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='post_settings-row', oob=oob) %}
|
||||||
|
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search) %}
|
||||||
|
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
settings
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% include '_types/post_settings/_nav.html' %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
17
templates/_types/post_settings/index.html
Normal file
17
templates/_types/post_settings/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends '_types/post/admin/index.html' %}
|
||||||
|
|
||||||
|
{% block post_admin_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('post-admin-header-child', '_types/post_settings/header/_header.html') %}
|
||||||
|
{% block post_settings_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include '_types/post_settings/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/post_settings/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
42
templates/_types/root/header/_header.html
Normal file
42
templates/_types/root/header/_header.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% set select_colours = "
|
||||||
|
[.hover-capable_&]:hover:bg-yellow-300
|
||||||
|
aria-selected:bg-stone-500 aria-selected:text-white
|
||||||
|
[.hover-capable_&[aria-selected=true]:hover]:bg-orange-500
|
||||||
|
"%}
|
||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='root-row', oob=oob) %}
|
||||||
|
<div class="w-full flex flex-row items-top">
|
||||||
|
{# Cart mini — fetched from cart app as fragment #}
|
||||||
|
{% if cart_mini_html %}
|
||||||
|
{{ cart_mini_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Site title #}
|
||||||
|
<div class="font-bold text-5xl flex-1">
|
||||||
|
{% from 'macros/title.html' import title with context %}
|
||||||
|
{{ title('flex justify-center md:justify-start')}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Desktop nav #}
|
||||||
|
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
|
||||||
|
{% if nav_tree_html %}
|
||||||
|
{{ nav_tree_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{# Auth menu — fetched from account app as fragment #}
|
||||||
|
{% if auth_menu_html %}
|
||||||
|
{{ auth_menu_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{% include "_types/root/_nav_panel.html"%}
|
||||||
|
</nav>
|
||||||
|
{% include '_types/root/_hamburger.html' %}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{# Mobile user info #}
|
||||||
|
<div class="block md:hidden">
|
||||||
|
{% if auth_menu_html %}
|
||||||
|
{{ auth_menu_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
32
templates/fragments/nav_tree.html
Normal file
32
templates/fragments/nav_tree.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{# Nav-tree fragment — rendered by blog, consumed by all apps.
|
||||||
|
Uses frag_app_name / frag_first_seg instead of request.path / app_name
|
||||||
|
so the consuming app's context is reflected correctly.
|
||||||
|
No hx-boost — cross-app nav links are full page navigations. #}
|
||||||
|
{% set _app_slugs = {
|
||||||
|
'cart': cart_url('/'),
|
||||||
|
'market': market_url('/'),
|
||||||
|
'events': events_url('/'),
|
||||||
|
'federation': federation_url('/'),
|
||||||
|
'account': account_url('/'),
|
||||||
|
} %}
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||||
|
id="menu-items-nav-wrapper">
|
||||||
|
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||||
|
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
|
||||||
|
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
|
||||||
|
<a
|
||||||
|
href="{{ _href }}"
|
||||||
|
aria-selected="{{ 'true' if (item.slug == frag_first_seg or item.slug == frag_app_name) else 'false' }}"
|
||||||
|
class="{{styles.nav_button_less_pad}}"
|
||||||
|
>
|
||||||
|
{% if item.feature_image %}
|
||||||
|
<img src="{{ item.feature_image }}"
|
||||||
|
alt="{{ item.label }}"
|
||||||
|
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||||
|
{% else %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
21
templates/macros/admin_nav.html
Normal file
21
templates/macros/admin_nav.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{#
|
||||||
|
Shared admin navigation macro
|
||||||
|
Use this instead of duplicate _nav.html files
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro admin_nav_item(href, icon='cog', label='', select_colours='', aclass=styles.nav_button) %}
|
||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% call links.link(href, hx_select_search, select_colours, True, aclass=aclass) %}
|
||||||
|
<i class="fa fa-{{ icon }}" aria-hidden="true"></i>
|
||||||
|
{{ label }}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro placeholder_nav() %}
|
||||||
|
{# Placeholder for admin sections without specific nav items #}
|
||||||
|
<div class="relative nav-group">
|
||||||
|
<span class="block px-3 py-2 text-stone-400 text-sm italic">
|
||||||
|
Admin options
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
68
templates/macros/scrolling_menu.html
Normal file
68
templates/macros/scrolling_menu.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{#
|
||||||
|
Scrolling menu macro with arrow navigation
|
||||||
|
|
||||||
|
Creates a horizontally scrollable menu (desktop) or vertically scrollable (mobile)
|
||||||
|
with arrow buttons that appear/hide based on content overflow.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- container_id: Unique ID for the scroll container
|
||||||
|
- items: List of items to iterate over
|
||||||
|
- item_content: Caller block that renders each item (receives 'item' variable)
|
||||||
|
- wrapper_class: Optional additional classes for outer wrapper
|
||||||
|
- container_class: Optional additional classes for scroll container
|
||||||
|
- item_class: Optional additional classes for each item wrapper
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro scrolling_menu(container_id, items, wrapper_class='', container_class='', item_class='') %}
|
||||||
|
{% if items %}
|
||||||
|
{# Left scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
_="on click
|
||||||
|
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
|
||||||
|
<i class="fa fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Scrollable container #}
|
||||||
|
<div id="{{ container_id }}"
|
||||||
|
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
|
||||||
|
style="scroll-behavior: smooth;"
|
||||||
|
_="on load or scroll
|
||||||
|
-- Show arrows if content overflows (desktop only)
|
||||||
|
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||||
|
remove .hidden from .scrolling-menu-arrow-{{ container_id }}
|
||||||
|
add .flex to .scrolling-menu-arrow-{{ container_id }}
|
||||||
|
else
|
||||||
|
add .hidden to .scrolling-menu-arrow-{{ container_id }}
|
||||||
|
remove .flex from .scrolling-menu-arrow-{{ container_id }}
|
||||||
|
end">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="{{ item_class }}">
|
||||||
|
{{ caller(item) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{# Right scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
_="on click
|
||||||
|
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
|
||||||
|
<i class="fa fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
24
templates/macros/stickers.html
Normal file
24
templates/macros/stickers.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% macro sticker(src, title, enabled, size=40, found=false) -%}
|
||||||
|
|
||||||
|
<span class="relative inline-flex items-center justify-center group"
|
||||||
|
tabindex="0" aria-label="{{ title|capitalize }}">
|
||||||
|
<!-- sticker icon -->
|
||||||
|
<img
|
||||||
|
src="{{ src }}"
|
||||||
|
width="{{size}}" height="{{size}}"
|
||||||
|
alt="{{ title|capitalize }}"
|
||||||
|
title="{{ title|capitalize }}"
|
||||||
|
class="{% if found %}border-2 border-yellow-200 bg-yellow-300{% endif %} {%if enabled %} opacity-100 {% else %} opacity-40 saturate-0 {% endif %}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- tooltip -->
|
||||||
|
<span role="tooltip"
|
||||||
|
class="pointer-events-none absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover/tt:block group-focus-visible/tt:block whitespace-nowrap rounded-md bg-stone-900 text-white text-xs px-2 py-1 shadow-lg">
|
||||||
|
{{ title|capitalize if title|lower != 'sugarfree' else 'Sugar' }}
|
||||||
|
<!-- little arrow -->
|
||||||
|
<span class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-stone-900"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
Reference in New Issue
Block a user