feat: extract shared infrastructure from shared_lib
Phase 1-3 of decoupling plan: - Shared DB, models, infrastructure, browser, config, utils - Event infrastructure (domain_events outbox, bus, processor) - Structured logging - Generic container concept (container_type/container_id) - Alembic migrations for all schema changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# shared package — extracted from blog/shared_lib/
|
||||||
68
alembic/env.py
Normal file
68
alembic/env.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os, sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
try:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add project root so all app model packages are importable
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||||
|
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
# Import ALL models so Base.metadata sees every table
|
||||||
|
import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost*
|
||||||
|
import blog.models # noqa: F401 Post, Author, Tag, Snippet, TagGroup
|
||||||
|
import market.models # noqa: F401 Product, CartItem, MarketPlace, etc.
|
||||||
|
import cart.models # noqa: F401 Order, OrderItem, PageConfig
|
||||||
|
import events.models # noqa: F401 Calendar, CalendarEntry, Ticket, etc.
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
def _get_url() -> str:
|
||||||
|
url = os.getenv(
|
||||||
|
"ALEMBIC_DATABASE_URL",
|
||||||
|
os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url") or "")
|
||||||
|
)
|
||||||
|
print(url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = _get_url()
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
url = _get_url()
|
||||||
|
if url:
|
||||||
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
|
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<%text>
|
||||||
|
# Alembic migration script template
|
||||||
|
</%text>
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
33
alembic/versions/0001_initial_schem.py
Normal file
33
alembic/versions/0001_initial_schem.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Initial database schema from schema.sql"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic
|
||||||
|
revision = '0001_initial_schema'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
return
|
||||||
|
schema_path = pathlib.Path(__file__).parent.parent.parent / "schema.sql"
|
||||||
|
with open(schema_path, encoding="utf-8") as f:
|
||||||
|
sql = f.read()
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text(sql))
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
return
|
||||||
|
# Drop all user-defined tables in the 'public' schema
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
DO $$ DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
78
alembic/versions/0002_add_cart_items.py
Normal file
78
alembic/versions/0002_add_cart_items.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Add cart_items table for shopping cart"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0002_add_cart_items"
|
||||||
|
down_revision = "0001_initial_schema"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"cart_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
|
||||||
|
# Either a logged-in user *or* an anonymous session_id
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("session_id", sa.String(length=128), nullable=True),
|
||||||
|
|
||||||
|
# IMPORTANT: reference products.id (PK), not slug
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("products.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
|
||||||
|
sa.Column(
|
||||||
|
"quantity",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"deleted_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes to speed up cart lookups
|
||||||
|
op.create_index(
|
||||||
|
"ix_cart_items_user_product",
|
||||||
|
"cart_items",
|
||||||
|
["user_id", "product_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_cart_items_session_product",
|
||||||
|
"cart_items",
|
||||||
|
["session_id", "product_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_cart_items_session_product", table_name="cart_items")
|
||||||
|
op.drop_index("ix_cart_items_user_product", table_name="cart_items")
|
||||||
|
op.drop_table("cart_items")
|
||||||
118
alembic/versions/0003_add_orders.py
Normal file
118
alembic/versions/0003_add_orders.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Add orders and order_items tables for checkout"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0003_add_orders"
|
||||||
|
down_revision = "0002_add_cart_items"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"orders",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("session_id", sa.String(length=64), nullable=True),
|
||||||
|
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"currency",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
server_default="GBP",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"total_amount",
|
||||||
|
sa.Numeric(12, 2),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
|
||||||
|
# SumUp integration fields
|
||||||
|
sa.Column("sumup_checkout_id", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("sumup_status", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("sumup_hosted_url", sa.Text(), nullable=True),
|
||||||
|
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes to match model hints (session_id + sumup_checkout_id index=True)
|
||||||
|
op.create_index(
|
||||||
|
"ix_orders_session_id",
|
||||||
|
"orders",
|
||||||
|
["session_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_orders_sumup_checkout_id",
|
||||||
|
"orders",
|
||||||
|
["sumup_checkout_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"order_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"order_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("orders.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"product_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("products.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("product_title", sa.String(length=512), nullable=True),
|
||||||
|
|
||||||
|
sa.Column(
|
||||||
|
"quantity",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"unit_price",
|
||||||
|
sa.Numeric(12, 2),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"currency",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
server_default="GBP",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("order_items")
|
||||||
|
op.drop_index("ix_orders_sumup_checkout_id", table_name="orders")
|
||||||
|
op.drop_index("ix_orders_session_id", table_name="orders")
|
||||||
|
op.drop_table("orders")
|
||||||
27
alembic/versions/0004_add_sumup_reference.py
Normal file
27
alembic/versions/0004_add_sumup_reference.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add sumup_reference to orders"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0004_add_sumup_reference"
|
||||||
|
down_revision = "0003_add_orders"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column("sumup_reference", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_orders_sumup_reference",
|
||||||
|
"orders",
|
||||||
|
["sumup_reference"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_orders_sumup_reference", table_name="orders")
|
||||||
|
op.drop_column("orders", "sumup_reference")
|
||||||
27
alembic/versions/0005_add_description.py
Normal file
27
alembic/versions/0005_add_description.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add description field to orders"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0005_add_description"
|
||||||
|
down_revision = "0004_add_sumup_reference"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_orders_description",
|
||||||
|
"orders",
|
||||||
|
["description"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_orders_description", table_name="orders")
|
||||||
|
op.drop_column("orders", "description")
|
||||||
28
alembic/versions/0006_update_calendar_entries.py
Normal file
28
alembic/versions/0006_update_calendar_entries.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0006_update_calendar_entries'
|
||||||
|
down_revision = '0005_add_description' # use the appropriate previous revision ID
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add user_id and session_id columns
|
||||||
|
op.add_column('calendar_entries', sa.Column('user_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key('fk_calendar_entries_user_id', 'calendar_entries', 'users', ['user_id'], ['id'])
|
||||||
|
op.add_column('calendar_entries', sa.Column('session_id', sa.String(length=128), nullable=True))
|
||||||
|
# Add state and cost columns
|
||||||
|
op.add_column('calendar_entries', sa.Column('state', sa.String(length=20), nullable=False, server_default='pending'))
|
||||||
|
op.add_column('calendar_entries', sa.Column('cost', sa.Numeric(10,2), nullable=False, server_default='10'))
|
||||||
|
# (Optional) Create indexes on the new columns
|
||||||
|
op.create_index('ix_calendar_entries_user_id', 'calendar_entries', ['user_id'])
|
||||||
|
op.create_index('ix_calendar_entries_session_id', 'calendar_entries', ['session_id'])
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('ix_calendar_entries_session_id', table_name='calendar_entries')
|
||||||
|
op.drop_index('ix_calendar_entries_user_id', table_name='calendar_entries')
|
||||||
|
op.drop_column('calendar_entries', 'cost')
|
||||||
|
op.drop_column('calendar_entries', 'state')
|
||||||
|
op.drop_column('calendar_entries', 'session_id')
|
||||||
|
op.drop_constraint('fk_calendar_entries_user_id', 'calendar_entries', type_='foreignkey')
|
||||||
|
op.drop_column('calendar_entries', 'user_id')
|
||||||
50
alembic/versions/0007_add_oid_entries.py
Normal file
50
alembic/versions/0007_add_oid_entries.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0007_add_oid_entries"
|
||||||
|
down_revision = "0006_update_calendar_entries"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add order_id column
|
||||||
|
op.add_column(
|
||||||
|
"calendar_entries",
|
||||||
|
sa.Column("order_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_calendar_entries_order_id",
|
||||||
|
"calendar_entries",
|
||||||
|
"orders",
|
||||||
|
["order_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_entries_order_id",
|
||||||
|
"calendar_entries",
|
||||||
|
["order_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional: add an index on state if you want faster queries by state
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_entries_state",
|
||||||
|
"calendar_entries",
|
||||||
|
["state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop indexes and FK in reverse order
|
||||||
|
op.drop_index("ix_calendar_entries_state", table_name="calendar_entries")
|
||||||
|
|
||||||
|
op.drop_index("ix_calendar_entries_order_id", table_name="calendar_entries")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_calendar_entries_order_id",
|
||||||
|
"calendar_entries",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("calendar_entries", "order_id")
|
||||||
33
alembic/versions/0008_add_flexible_to_slots.py
Normal file
33
alembic/versions/0008_add_flexible_to_slots.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""add flexible flag to calendar_slots
|
||||||
|
|
||||||
|
Revision ID: 0008_add_flexible_to_calendar_slots
|
||||||
|
Revises: 0007_add_order_id_to_calendar_entries
|
||||||
|
Create Date: 2025-12-06 12:34:56.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0008_add_flexible_to_slots"
|
||||||
|
down_revision = "0007_add_oid_entries"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"calendar_slots",
|
||||||
|
sa.Column(
|
||||||
|
"flexible",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(), # set existing rows to False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Optional: drop server_default so future inserts must supply a value
|
||||||
|
op.alter_column("calendar_slots", "flexible", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("calendar_slots", "flexible")
|
||||||
54
alembic/versions/0009_add_slot_id_to_entries.py
Normal file
54
alembic/versions/0009_add_slot_id_to_entries.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""add slot_id to calendar_entries
|
||||||
|
|
||||||
|
Revision ID: 0009_add_slot_id_to_entries
|
||||||
|
Revises: 0008_add_flexible_to_slots
|
||||||
|
Create Date: 2025-12-06 13:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0009_add_slot_id_to_entries"
|
||||||
|
down_revision = "0008_add_flexible_to_slots"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add slot_id column as nullable initially
|
||||||
|
op.add_column(
|
||||||
|
"calendar_entries",
|
||||||
|
sa.Column(
|
||||||
|
"slot_id",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add foreign key constraint
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_calendar_entries_slot_id_calendar_slots",
|
||||||
|
"calendar_entries",
|
||||||
|
"calendar_slots",
|
||||||
|
["slot_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add index for better query performance
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_entries_slot_id",
|
||||||
|
"calendar_entries",
|
||||||
|
["slot_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_calendar_entries_slot_id", table_name="calendar_entries")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_calendar_entries_slot_id_calendar_slots",
|
||||||
|
"calendar_entries",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("calendar_entries", "slot_id")
|
||||||
64
alembic/versions/0010_add_post_likes.py
Normal file
64
alembic/versions/0010_add_post_likes.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Add post_likes table for liking blog posts
|
||||||
|
|
||||||
|
Revision ID: 0010_add_post_likes
|
||||||
|
Revises: 0009_add_slot_id_to_entries
|
||||||
|
Create Date: 2025-12-07 13:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0010_add_post_likes"
|
||||||
|
down_revision = "0009_add_slot_id_to_entries"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"post_likes",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"post_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("posts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"deleted_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for fast user+post lookups
|
||||||
|
op.create_index(
|
||||||
|
"ix_post_likes_user_post",
|
||||||
|
"post_likes",
|
||||||
|
["user_id", "post_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_post_likes_user_post", table_name="post_likes")
|
||||||
|
op.drop_table("post_likes")
|
||||||
43
alembic/versions/0011_add_entry_tickets.py
Normal file
43
alembic/versions/0011_add_entry_tickets.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Add ticket_price and ticket_count to calendar_entries
|
||||||
|
|
||||||
|
Revision ID: 0011_add_entry_tickets
|
||||||
|
Revises: 0010_add_post_likes
|
||||||
|
Create Date: 2025-12-07 14:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import NUMERIC
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0011_add_entry_tickets"
|
||||||
|
down_revision = "0010_add_post_likes"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add ticket_price column (nullable - NULL means no tickets)
|
||||||
|
op.add_column(
|
||||||
|
"calendar_entries",
|
||||||
|
sa.Column(
|
||||||
|
"ticket_price",
|
||||||
|
NUMERIC(10, 2),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add ticket_count column (nullable - NULL means unlimited)
|
||||||
|
op.add_column(
|
||||||
|
"calendar_entries",
|
||||||
|
sa.Column(
|
||||||
|
"ticket_count",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("calendar_entries", "ticket_count")
|
||||||
|
op.drop_column("calendar_entries", "ticket_price")
|
||||||
41
alembic/versions/47fc53fc0d2b_add_ticket_types_table.py
Normal file
41
alembic/versions/47fc53fc0d2b_add_ticket_types_table.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
# Alembic migration script template
|
||||||
|
|
||||||
|
"""add ticket_types table
|
||||||
|
|
||||||
|
Revision ID: 47fc53fc0d2b
|
||||||
|
Revises: a9f54e4eaf02
|
||||||
|
Create Date: 2025-12-08 07:29:11.422435
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '47fc53fc0d2b'
|
||||||
|
down_revision = 'a9f54e4eaf02'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'ticket_types',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('entry_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||||
|
sa.Column('count', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['entry_id'], ['calendar_entries.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_ticket_types_entry_id', 'ticket_types', ['entry_id'], unique=False)
|
||||||
|
op.create_index('ix_ticket_types_name', 'ticket_types', ['name'], unique=False)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_ticket_types_name', table_name='ticket_types')
|
||||||
|
op.drop_index('ix_ticket_types_entry_id', table_name='ticket_types')
|
||||||
|
op.drop_table('ticket_types')
|
||||||
36
alembic/versions/6cb124491c9d_entry_posts.py
Normal file
36
alembic/versions/6cb124491c9d_entry_posts.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
# Alembic migration script template
|
||||||
|
|
||||||
|
"""Add calendar_entry_posts association table
|
||||||
|
|
||||||
|
Revision ID: 6cb124491c9d
|
||||||
|
Revises: 0011_add_entry_tickets
|
||||||
|
Create Date: 2025-12-07 03:40:49.194068
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import TIMESTAMP
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6cb124491c9d'
|
||||||
|
down_revision = '0011_add_entry_tickets'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'calendar_entry_posts',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('entry_id', sa.Integer(), sa.ForeignKey('calendar_entries.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), sa.ForeignKey('posts.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('created_at', TIMESTAMP(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('deleted_at', TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index('ix_entry_posts_entry_id', 'calendar_entry_posts', ['entry_id'])
|
||||||
|
op.create_index('ix_entry_posts_post_id', 'calendar_entry_posts', ['post_id'])
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_entry_posts_post_id', 'calendar_entry_posts')
|
||||||
|
op.drop_index('ix_entry_posts_entry_id', 'calendar_entry_posts')
|
||||||
|
op.drop_table('calendar_entry_posts')
|
||||||
74
alembic/versions/a1b2c3d4e5f6_add_page_configs_table.py
Normal file
74
alembic/versions/a1b2c3d4e5f6_add_page_configs_table.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""add page_configs table
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: f6d4a1b2c3e7
|
||||||
|
Create Date: 2026-02-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = 'a1b2c3d4e5f6'
|
||||||
|
down_revision = 'f6d4a1b2c3e7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'page_configs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('features', sa.JSON(), server_default='{}', nullable=False),
|
||||||
|
sa.Column('sumup_merchant_code', sa.String(64), nullable=True),
|
||||||
|
sa.Column('sumup_api_key', sa.Text(), nullable=True),
|
||||||
|
sa.Column('sumup_checkout_prefix', sa.String(64), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('post_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill: create PageConfig for every existing page
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Pages with calendars -> features={"calendar": true}
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO page_configs (post_id, features, created_at, updated_at)
|
||||||
|
SELECT p.id, '{"calendar": true}'::jsonb, now(), now()
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.is_page = true
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM calendars c
|
||||||
|
WHERE c.post_id = p.id AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 2. Market page (slug='market', is_page=true) -> features={"market": true}
|
||||||
|
# Only if not already inserted above
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO page_configs (post_id, features, created_at, updated_at)
|
||||||
|
SELECT p.id, '{"market": true}'::jsonb, now(), now()
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.slug = 'market'
|
||||||
|
AND p.is_page = true
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND p.id NOT IN (SELECT post_id FROM page_configs)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. All other pages -> features={}
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO page_configs (post_id, features, created_at, updated_at)
|
||||||
|
SELECT p.id, '{}'::jsonb, now(), now()
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.is_page = true
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND p.id NOT IN (SELECT post_id FROM page_configs)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('page_configs')
|
||||||
37
alembic/versions/a9f54e4eaf02_add_menu_items_table.py
Normal file
37
alembic/versions/a9f54e4eaf02_add_menu_items_table.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
# Alembic migration script template
|
||||||
|
|
||||||
|
"""add menu_items table
|
||||||
|
|
||||||
|
Revision ID: a9f54e4eaf02
|
||||||
|
Revises: 6cb124491c9d
|
||||||
|
Create Date: 2025-12-07 17:38:54.839296
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a9f54e4eaf02'
|
||||||
|
down_revision = '6cb124491c9d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('menu_items',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_menu_items_post_id'), 'menu_items', ['post_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_menu_items_sort_order'), 'menu_items', ['sort_order'], unique=False)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_menu_items_sort_order'), table_name='menu_items')
|
||||||
|
op.drop_index(op.f('ix_menu_items_post_id'), table_name='menu_items')
|
||||||
|
op.drop_table('menu_items')
|
||||||
97
alembic/versions/b2c3d4e5f6a7_add_market_places_table.py
Normal file
97
alembic/versions/b2c3d4e5f6a7_add_market_places_table.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""add market_places table and nav_tops.market_id
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-02-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = 'b2c3d4e5f6a7'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Create market_places table
|
||||||
|
op.create_table(
|
||||||
|
'market_places',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('post_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_market_places_post_id', 'market_places', ['post_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ux_market_places_slug_active',
|
||||||
|
'market_places',
|
||||||
|
[sa.text('lower(slug)')],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text('deleted_at IS NULL'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Add market_id column to nav_tops
|
||||||
|
op.add_column(
|
||||||
|
'nav_tops',
|
||||||
|
sa.Column('market_id', sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_nav_tops_market_id',
|
||||||
|
'nav_tops',
|
||||||
|
'market_places',
|
||||||
|
['market_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='SET NULL',
|
||||||
|
)
|
||||||
|
op.create_index('ix_nav_tops_market_id', 'nav_tops', ['market_id'])
|
||||||
|
|
||||||
|
# 3. Backfill: create default MarketPlace for the 'market' page
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Find the market page
|
||||||
|
result = conn.execute(text("""
|
||||||
|
SELECT id FROM posts
|
||||||
|
WHERE slug = 'market' AND is_page = true AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
"""))
|
||||||
|
row = result.fetchone()
|
||||||
|
if row:
|
||||||
|
post_id = row[0]
|
||||||
|
|
||||||
|
# Insert the default market
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO market_places (post_id, name, slug, created_at, updated_at)
|
||||||
|
VALUES (:post_id, 'Suma Market', 'suma-market', now(), now())
|
||||||
|
"""), {"post_id": post_id})
|
||||||
|
|
||||||
|
# Get the new market_places id
|
||||||
|
market_row = conn.execute(text("""
|
||||||
|
SELECT id FROM market_places
|
||||||
|
WHERE slug = 'suma-market' AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
if market_row:
|
||||||
|
market_id = market_row[0]
|
||||||
|
# Assign all active nav_tops to this market
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE nav_tops SET market_id = :market_id
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
"""), {"market_id": market_id})
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_nav_tops_market_id', table_name='nav_tops')
|
||||||
|
op.drop_constraint('fk_nav_tops_market_id', 'nav_tops', type_='foreignkey')
|
||||||
|
op.drop_column('nav_tops', 'market_id')
|
||||||
|
op.drop_index('ux_market_places_slug_active', table_name='market_places')
|
||||||
|
op.drop_index('ix_market_places_post_id', table_name='market_places')
|
||||||
|
op.drop_table('market_places')
|
||||||
35
alembic/versions/c3a1f7b9d4e5_add_snippets_table.py
Normal file
35
alembic/versions/c3a1f7b9d4e5_add_snippets_table.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""add snippets table
|
||||||
|
|
||||||
|
Revision ID: c3a1f7b9d4e5
|
||||||
|
Revises: 47fc53fc0d2b
|
||||||
|
Create Date: 2026-02-07
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'c3a1f7b9d4e5'
|
||||||
|
down_revision = '47fc53fc0d2b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'snippets',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('value', sa.Text(), nullable=False),
|
||||||
|
sa.Column('visibility', sa.String(length=20), server_default='private', nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'name', name='uq_snippets_user_name'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_snippets_visibility', 'snippets', ['visibility'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_snippets_visibility', table_name='snippets')
|
||||||
|
op.drop_table('snippets')
|
||||||
55
alembic/versions/c3d4e5f6a7b8_add_page_tracking_to_orders.py
Normal file
55
alembic/versions/c3d4e5f6a7b8_add_page_tracking_to_orders.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""add page_config_id to orders, market_place_id to cart_items
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-02-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'c3d4e5f6a7b8'
|
||||||
|
down_revision = 'b2c3d4e5f6a7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add market_place_id to cart_items
|
||||||
|
op.add_column(
|
||||||
|
'cart_items',
|
||||||
|
sa.Column('market_place_id', sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_cart_items_market_place_id',
|
||||||
|
'cart_items',
|
||||||
|
'market_places',
|
||||||
|
['market_place_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='SET NULL',
|
||||||
|
)
|
||||||
|
op.create_index('ix_cart_items_market_place_id', 'cart_items', ['market_place_id'])
|
||||||
|
|
||||||
|
# 2. Add page_config_id to orders
|
||||||
|
op.add_column(
|
||||||
|
'orders',
|
||||||
|
sa.Column('page_config_id', sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_orders_page_config_id',
|
||||||
|
'orders',
|
||||||
|
'page_configs',
|
||||||
|
['page_config_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='SET NULL',
|
||||||
|
)
|
||||||
|
op.create_index('ix_orders_page_config_id', 'orders', ['page_config_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_orders_page_config_id', table_name='orders')
|
||||||
|
op.drop_constraint('fk_orders_page_config_id', 'orders', type_='foreignkey')
|
||||||
|
op.drop_column('orders', 'page_config_id')
|
||||||
|
|
||||||
|
op.drop_index('ix_cart_items_market_place_id', table_name='cart_items')
|
||||||
|
op.drop_constraint('fk_cart_items_market_place_id', 'cart_items', type_='foreignkey')
|
||||||
|
op.drop_column('cart_items', 'market_place_id')
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""add post user_id, author email, publish_requested
|
||||||
|
|
||||||
|
Revision ID: d4b2e8f1a3c7
|
||||||
|
Revises: c3a1f7b9d4e5
|
||||||
|
Create Date: 2026-02-08
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'd4b2e8f1a3c7'
|
||||||
|
down_revision = 'c3a1f7b9d4e5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add author.email
|
||||||
|
op.add_column('authors', sa.Column('email', sa.String(255), nullable=True))
|
||||||
|
|
||||||
|
# Add post.user_id FK
|
||||||
|
op.add_column('posts', sa.Column('user_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key('fk_posts_user_id', 'posts', 'users', ['user_id'], ['id'], ondelete='SET NULL')
|
||||||
|
op.create_index('ix_posts_user_id', 'posts', ['user_id'])
|
||||||
|
|
||||||
|
# Add post.publish_requested
|
||||||
|
op.add_column('posts', sa.Column('publish_requested', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
|
||||||
|
# Backfill: match posts to users via primary_author email
|
||||||
|
op.execute("""
|
||||||
|
UPDATE posts
|
||||||
|
SET user_id = u.id
|
||||||
|
FROM authors a
|
||||||
|
JOIN users u ON lower(a.email) = lower(u.email)
|
||||||
|
WHERE posts.primary_author_id = a.id
|
||||||
|
AND posts.user_id IS NULL
|
||||||
|
AND a.email IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('posts', 'publish_requested')
|
||||||
|
op.drop_index('ix_posts_user_id', table_name='posts')
|
||||||
|
op.drop_constraint('fk_posts_user_id', 'posts', type_='foreignkey')
|
||||||
|
op.drop_column('posts', 'user_id')
|
||||||
|
op.drop_column('authors', 'email')
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""add tag_groups and tag_group_tags
|
||||||
|
|
||||||
|
Revision ID: e5c3f9a2b1d6
|
||||||
|
Revises: d4b2e8f1a3c7
|
||||||
|
Create Date: 2026-02-08
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'e5c3f9a2b1d6'
|
||||||
|
down_revision = 'd4b2e8f1a3c7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'tag_groups',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=191), nullable=False),
|
||||||
|
sa.Column('feature_image', sa.Text(), nullable=True),
|
||||||
|
sa.Column('colour', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('slug'),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'tag_group_tags',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('tag_group_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tag_group_id'], ['tag_groups.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('tag_group_id', 'tag_id', name='uq_tag_group_tag'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('tag_group_tags')
|
||||||
|
op.drop_table('tag_groups')
|
||||||
40
alembic/versions/f6d4a0b2c3e7_add_domain_events_table.py
Normal file
40
alembic/versions/f6d4a0b2c3e7_add_domain_events_table.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""add domain_events table
|
||||||
|
|
||||||
|
Revision ID: f6d4a0b2c3e7
|
||||||
|
Revises: e5c3f9a2b1d6
|
||||||
|
Create Date: 2026-02-11
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = 'f6d4a0b2c3e7'
|
||||||
|
down_revision = 'e5c3f9a2b1d6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'domain_events',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('event_type', sa.String(128), nullable=False),
|
||||||
|
sa.Column('aggregate_type', sa.String(64), nullable=False),
|
||||||
|
sa.Column('aggregate_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('state', sa.String(20), server_default='pending', nullable=False),
|
||||||
|
sa.Column('attempts', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('max_attempts', sa.Integer(), server_default='5', nullable=False),
|
||||||
|
sa.Column('last_error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_domain_events_event_type', 'domain_events', ['event_type'])
|
||||||
|
op.create_index('ix_domain_events_state', 'domain_events', ['state'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_domain_events_state', table_name='domain_events')
|
||||||
|
op.drop_index('ix_domain_events_event_type', table_name='domain_events')
|
||||||
|
op.drop_table('domain_events')
|
||||||
47
alembic/versions/f6d4a1b2c3e7_add_tickets_table.py
Normal file
47
alembic/versions/f6d4a1b2c3e7_add_tickets_table.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""add tickets table
|
||||||
|
|
||||||
|
Revision ID: f6d4a1b2c3e7
|
||||||
|
Revises: e5c3f9a2b1d6
|
||||||
|
Create Date: 2026-02-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'f6d4a1b2c3e7'
|
||||||
|
down_revision = 'e5c3f9a2b1d6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'tickets',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('entry_id', sa.Integer(), sa.ForeignKey('calendar_entries.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('ticket_type_id', sa.Integer(), sa.ForeignKey('ticket_types.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('session_id', sa.String(64), nullable=True),
|
||||||
|
sa.Column('order_id', sa.Integer(), sa.ForeignKey('orders.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('code', sa.String(64), unique=True, nullable=False),
|
||||||
|
sa.Column('state', sa.String(20), nullable=False, server_default=sa.text("'reserved'")),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('checked_in_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index('ix_tickets_entry_id', 'tickets', ['entry_id'])
|
||||||
|
op.create_index('ix_tickets_ticket_type_id', 'tickets', ['ticket_type_id'])
|
||||||
|
op.create_index('ix_tickets_user_id', 'tickets', ['user_id'])
|
||||||
|
op.create_index('ix_tickets_session_id', 'tickets', ['session_id'])
|
||||||
|
op.create_index('ix_tickets_order_id', 'tickets', ['order_id'])
|
||||||
|
op.create_index('ix_tickets_code', 'tickets', ['code'], unique=True)
|
||||||
|
op.create_index('ix_tickets_state', 'tickets', ['state'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_tickets_state', 'tickets')
|
||||||
|
op.drop_index('ix_tickets_code', 'tickets')
|
||||||
|
op.drop_index('ix_tickets_order_id', 'tickets')
|
||||||
|
op.drop_index('ix_tickets_session_id', 'tickets')
|
||||||
|
op.drop_index('ix_tickets_user_id', 'tickets')
|
||||||
|
op.drop_index('ix_tickets_ticket_type_id', 'tickets')
|
||||||
|
op.drop_index('ix_tickets_entry_id', 'tickets')
|
||||||
|
op.drop_table('tickets')
|
||||||
115
alembic/versions/g7e5b1c3d4f8_generic_containers.py
Normal file
115
alembic/versions/g7e5b1c3d4f8_generic_containers.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""replace post_id FKs with container_type + container_id
|
||||||
|
|
||||||
|
Revision ID: g7e5b1c3d4f8
|
||||||
|
Revises: f6d4a0b2c3e7
|
||||||
|
Create Date: 2026-02-11
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'g7e5b1c3d4f8'
|
||||||
|
down_revision = 'f6d4a0b2c3e7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- calendars: post_id → container_type + container_id ---
|
||||||
|
op.add_column('calendars', sa.Column('container_type', sa.String(32), nullable=True))
|
||||||
|
op.add_column('calendars', sa.Column('container_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE calendars SET container_type = 'page', container_id = post_id")
|
||||||
|
op.alter_column('calendars', 'container_type', nullable=False, server_default=sa.text("'page'"))
|
||||||
|
op.alter_column('calendars', 'container_id', nullable=False)
|
||||||
|
op.drop_index('ix_calendars_post_id', table_name='calendars')
|
||||||
|
op.drop_index('ux_calendars_post_slug_active', table_name='calendars')
|
||||||
|
op.drop_constraint('calendars_post_id_fkey', 'calendars', type_='foreignkey')
|
||||||
|
op.drop_column('calendars', 'post_id')
|
||||||
|
op.create_index('ix_calendars_container', 'calendars', ['container_type', 'container_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ux_calendars_container_slug_active',
|
||||||
|
'calendars',
|
||||||
|
['container_type', 'container_id', sa.text('lower(slug)')],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text('deleted_at IS NULL'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- market_places: post_id → container_type + container_id ---
|
||||||
|
op.add_column('market_places', sa.Column('container_type', sa.String(32), nullable=True))
|
||||||
|
op.add_column('market_places', sa.Column('container_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE market_places SET container_type = 'page', container_id = post_id")
|
||||||
|
op.alter_column('market_places', 'container_type', nullable=False, server_default=sa.text("'page'"))
|
||||||
|
op.alter_column('market_places', 'container_id', nullable=False)
|
||||||
|
op.drop_index('ix_market_places_post_id', table_name='market_places')
|
||||||
|
op.drop_constraint('market_places_post_id_fkey', 'market_places', type_='foreignkey')
|
||||||
|
op.drop_column('market_places', 'post_id')
|
||||||
|
op.create_index('ix_market_places_container', 'market_places', ['container_type', 'container_id'])
|
||||||
|
|
||||||
|
# --- page_configs: post_id → container_type + container_id ---
|
||||||
|
op.add_column('page_configs', sa.Column('container_type', sa.String(32), nullable=True))
|
||||||
|
op.add_column('page_configs', sa.Column('container_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE page_configs SET container_type = 'page', container_id = post_id")
|
||||||
|
op.alter_column('page_configs', 'container_type', nullable=False, server_default=sa.text("'page'"))
|
||||||
|
op.alter_column('page_configs', 'container_id', nullable=False)
|
||||||
|
op.drop_constraint('page_configs_post_id_fkey', 'page_configs', type_='foreignkey')
|
||||||
|
op.drop_column('page_configs', 'post_id')
|
||||||
|
op.create_index('ix_page_configs_container', 'page_configs', ['container_type', 'container_id'])
|
||||||
|
|
||||||
|
# --- calendar_entry_posts: post_id → content_type + content_id ---
|
||||||
|
op.add_column('calendar_entry_posts', sa.Column('content_type', sa.String(32), nullable=True))
|
||||||
|
op.add_column('calendar_entry_posts', sa.Column('content_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE calendar_entry_posts SET content_type = 'post', content_id = post_id")
|
||||||
|
op.alter_column('calendar_entry_posts', 'content_type', nullable=False, server_default=sa.text("'post'"))
|
||||||
|
op.alter_column('calendar_entry_posts', 'content_id', nullable=False)
|
||||||
|
op.drop_index('ix_entry_posts_post_id', table_name='calendar_entry_posts')
|
||||||
|
op.drop_constraint('calendar_entry_posts_post_id_fkey', 'calendar_entry_posts', type_='foreignkey')
|
||||||
|
op.drop_column('calendar_entry_posts', 'post_id')
|
||||||
|
op.create_index('ix_entry_posts_content', 'calendar_entry_posts', ['content_type', 'content_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# --- calendar_entry_posts: restore post_id ---
|
||||||
|
op.add_column('calendar_entry_posts', sa.Column('post_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE calendar_entry_posts SET post_id = content_id WHERE content_type = 'post'")
|
||||||
|
op.alter_column('calendar_entry_posts', 'post_id', nullable=False)
|
||||||
|
op.create_foreign_key('calendar_entry_posts_post_id_fkey', 'calendar_entry_posts', 'posts', ['post_id'], ['id'], ondelete='CASCADE')
|
||||||
|
op.create_index('ix_entry_posts_post_id', 'calendar_entry_posts', ['post_id'])
|
||||||
|
op.drop_index('ix_entry_posts_content', table_name='calendar_entry_posts')
|
||||||
|
op.drop_column('calendar_entry_posts', 'content_id')
|
||||||
|
op.drop_column('calendar_entry_posts', 'content_type')
|
||||||
|
|
||||||
|
# --- page_configs: restore post_id ---
|
||||||
|
op.add_column('page_configs', sa.Column('post_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE page_configs SET post_id = container_id WHERE container_type = 'page'")
|
||||||
|
op.alter_column('page_configs', 'post_id', nullable=False)
|
||||||
|
op.create_foreign_key('page_configs_post_id_fkey', 'page_configs', 'posts', ['post_id'], ['id'], ondelete='CASCADE')
|
||||||
|
op.drop_index('ix_page_configs_container', table_name='page_configs')
|
||||||
|
op.drop_column('page_configs', 'container_id')
|
||||||
|
op.drop_column('page_configs', 'container_type')
|
||||||
|
|
||||||
|
# --- market_places: restore post_id ---
|
||||||
|
op.add_column('market_places', sa.Column('post_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE market_places SET post_id = container_id WHERE container_type = 'page'")
|
||||||
|
op.alter_column('market_places', 'post_id', nullable=False)
|
||||||
|
op.create_foreign_key('market_places_post_id_fkey', 'market_places', 'posts', ['post_id'], ['id'], ondelete='CASCADE')
|
||||||
|
op.create_index('ix_market_places_post_id', 'market_places', ['post_id'])
|
||||||
|
op.drop_index('ix_market_places_container', table_name='market_places')
|
||||||
|
op.drop_column('market_places', 'container_id')
|
||||||
|
op.drop_column('market_places', 'container_type')
|
||||||
|
|
||||||
|
# --- calendars: restore post_id ---
|
||||||
|
op.add_column('calendars', sa.Column('post_id', sa.Integer(), nullable=True))
|
||||||
|
op.execute("UPDATE calendars SET post_id = container_id WHERE container_type = 'page'")
|
||||||
|
op.alter_column('calendars', 'post_id', nullable=False)
|
||||||
|
op.create_foreign_key('calendars_post_id_fkey', 'calendars', 'posts', ['post_id'], ['id'], ondelete='CASCADE')
|
||||||
|
op.create_index('ix_calendars_post_id', 'calendars', ['post_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ux_calendars_post_slug_active',
|
||||||
|
'calendars',
|
||||||
|
['post_id', sa.text('lower(slug)')],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text('deleted_at IS NULL'),
|
||||||
|
)
|
||||||
|
op.drop_index('ux_calendars_container_slug_active', table_name='calendars')
|
||||||
|
op.drop_index('ix_calendars_container', table_name='calendars')
|
||||||
|
op.drop_column('calendars', 'container_id')
|
||||||
|
op.drop_column('calendars', 'container_type')
|
||||||
1
browser/__init__.py
Normal file
1
browser/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# suma_browser package
|
||||||
12
browser/app/__init__.py
Normal file
12
browser/app/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# The monolith has been split into three apps (apps/coop, apps/market, apps/cart).
|
||||||
|
# This package remains for shared infrastructure modules (middleware, redis_cacher,
|
||||||
|
# csrf, errors, authz, filters, utils, bp/*).
|
||||||
|
#
|
||||||
|
# To run individual apps:
|
||||||
|
# hypercorn apps.coop.app:app --bind 0.0.0.0:8000
|
||||||
|
# hypercorn apps.market.app:app --bind 0.0.0.0:8001
|
||||||
|
# hypercorn apps.cart.app:app --bind 0.0.0.0:8002
|
||||||
|
#
|
||||||
|
# Legacy single-process:
|
||||||
|
# hypercorn suma_browser.app.app:app --bind 0.0.0.0:8000
|
||||||
|
# (runs the old monolith from app.py, which still works)
|
||||||
152
browser/app/authz.py
Normal file
152
browser/app/authz.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Dict, Iterable, Optional
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from quart import g, abort, redirect, request, current_app
|
||||||
|
from shared.infrastructure.urls import login_url
|
||||||
|
|
||||||
|
|
||||||
|
def require_rights(*rights: str, any_of: bool = True):
|
||||||
|
"""
|
||||||
|
Decorator for routes that require certain user rights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rights:
|
||||||
|
raise ValueError("require_rights needs at least one right name")
|
||||||
|
|
||||||
|
required_set = frozenset(rights)
|
||||||
|
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any):
|
||||||
|
# Not logged in → go to login, with ?next=<current path>
|
||||||
|
user = g.get("user")
|
||||||
|
if not user:
|
||||||
|
return redirect(login_url(request.url))
|
||||||
|
|
||||||
|
rights_dict = g.get("rights") or {}
|
||||||
|
|
||||||
|
if any_of:
|
||||||
|
allowed = any(rights_dict.get(name) for name in required_set)
|
||||||
|
else:
|
||||||
|
allowed = all(rights_dict.get(name) for name in required_set)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
result = view_func(*args, **kwargs)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ---- expose access requirements on the wrapper ----
|
||||||
|
wrapper.__access_requires__ = {
|
||||||
|
"rights": required_set,
|
||||||
|
"any_of": any_of,
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def require_login(view_func):
|
||||||
|
"""
|
||||||
|
Decorator for routes that require any logged-in user.
|
||||||
|
"""
|
||||||
|
@wraps(view_func)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any):
|
||||||
|
user = g.get("user")
|
||||||
|
if not user:
|
||||||
|
return redirect(login_url(request.url))
|
||||||
|
result = view_func(*args, **kwargs)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(view_func=None):
|
||||||
|
"""
|
||||||
|
Shortcut for routes that require the 'admin' right.
|
||||||
|
"""
|
||||||
|
if view_func is None:
|
||||||
|
return require_rights("admin")
|
||||||
|
|
||||||
|
return require_rights("admin")(view_func)
|
||||||
|
|
||||||
|
def require_post_author(view_func):
|
||||||
|
"""Allow admin or post owner."""
|
||||||
|
@wraps(view_func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
user = g.get("user")
|
||||||
|
if not user:
|
||||||
|
return redirect(login_url(request.url))
|
||||||
|
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||||
|
if is_admin:
|
||||||
|
result = view_func(*args, **kwargs)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
post = getattr(g, "post_data", {}).get("original_post")
|
||||||
|
if post and post.user_id == user.id:
|
||||||
|
result = view_func(*args, **kwargs)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
abort(403)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _get_access_meta(view_func) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Walk the wrapper chain looking for __access_requires__ metadata.
|
||||||
|
"""
|
||||||
|
func = view_func
|
||||||
|
seen: set[int] = set()
|
||||||
|
|
||||||
|
while func is not None and id(func) not in seen:
|
||||||
|
seen.add(id(func))
|
||||||
|
meta = getattr(func, "__access_requires__", None)
|
||||||
|
if meta is not None:
|
||||||
|
return meta
|
||||||
|
func = getattr(func, "__wrapped__", None)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_access(endpoint: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the current user has access to the given endpoint.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
has_access("settings.home")
|
||||||
|
has_access("settings.clear_cache_view")
|
||||||
|
"""
|
||||||
|
view = current_app.view_functions.get(endpoint)
|
||||||
|
if view is None:
|
||||||
|
# Unknown endpoint: be conservative
|
||||||
|
return False
|
||||||
|
|
||||||
|
meta = _get_access_meta(view)
|
||||||
|
|
||||||
|
# If the route has no rights metadata, treat it as public:
|
||||||
|
if meta is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
required: Iterable[str] = meta["rights"]
|
||||||
|
any_of: bool = meta["any_of"]
|
||||||
|
|
||||||
|
# Must be in a request context; if no user, they don't have access
|
||||||
|
user = g.get("user")
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rights_dict = g.get("rights") or {}
|
||||||
|
|
||||||
|
if any_of:
|
||||||
|
return any(rights_dict.get(name) for name in required)
|
||||||
|
else:
|
||||||
|
return all(rights_dict.get(name) for name in required)
|
||||||
99
browser/app/csrf.py
Normal file
99
browser/app/csrf.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Callable, Awaitable, Optional
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
abort,
|
||||||
|
current_app,
|
||||||
|
request,
|
||||||
|
session as qsession,
|
||||||
|
)
|
||||||
|
|
||||||
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csrf_token() -> str:
|
||||||
|
"""
|
||||||
|
Per-session CSRF token.
|
||||||
|
|
||||||
|
In Jinja:
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
"""
|
||||||
|
token = qsession.get("csrf_token")
|
||||||
|
if not token:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
qsession["csrf_token"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _is_exempt_endpoint() -> bool:
|
||||||
|
endpoint = request.endpoint
|
||||||
|
if not endpoint:
|
||||||
|
return False
|
||||||
|
view = current_app.view_functions.get(endpoint)
|
||||||
|
|
||||||
|
# Walk decorator stack (__wrapped__) to find csrf_exempt
|
||||||
|
while view is not None:
|
||||||
|
if getattr(view, "_csrf_exempt", False):
|
||||||
|
return True
|
||||||
|
view = getattr(view, "__wrapped__", None)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def protect() -> None:
|
||||||
|
"""
|
||||||
|
Enforce CSRF on unsafe methods.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
* Forms: hidden input "csrf_token"
|
||||||
|
* JSON: "csrf_token" or "csrfToken" field
|
||||||
|
* HTMX/AJAX: "X-CSRFToken" or "X-CSRF-Token" header
|
||||||
|
"""
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_exempt_endpoint():
|
||||||
|
return
|
||||||
|
|
||||||
|
session_token = qsession.get("csrf_token")
|
||||||
|
if not session_token:
|
||||||
|
abort(400, "Missing CSRF session token")
|
||||||
|
|
||||||
|
supplied_token: Optional[str] = None
|
||||||
|
|
||||||
|
# JSON body
|
||||||
|
if request.mimetype == "application/json":
|
||||||
|
data = await request.get_json(silent=True) or {}
|
||||||
|
supplied_token = data.get("csrf_token") or data.get("csrfToken")
|
||||||
|
|
||||||
|
# Form body
|
||||||
|
if not supplied_token and request.mimetype != "application/json":
|
||||||
|
form = await request.form
|
||||||
|
supplied_token = form.get("csrf_token")
|
||||||
|
|
||||||
|
# Headers (HTMX / fetch)
|
||||||
|
if not supplied_token:
|
||||||
|
supplied_token = (
|
||||||
|
request.headers.get("X-CSRFToken")
|
||||||
|
or request.headers.get("X-CSRF-Token")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not supplied_token or supplied_token != session_token:
|
||||||
|
abort(400, "Invalid CSRF token")
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_exempt(view: Callable[..., Awaitable]) -> Callable[..., Awaitable]:
|
||||||
|
"""
|
||||||
|
Mark a view as CSRF-exempt.
|
||||||
|
|
||||||
|
from suma_browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@blueprint.post("/hook")
|
||||||
|
async def webhook():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
setattr(view, "_csrf_exempt", True)
|
||||||
|
return view
|
||||||
126
browser/app/errors.py
Normal file
126
browser/app/errors.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
from shared.utils import hx_fragment_request
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
request,
|
||||||
|
render_template,
|
||||||
|
make_response,
|
||||||
|
current_app
|
||||||
|
)
|
||||||
|
|
||||||
|
from markupsafe import escape
|
||||||
|
|
||||||
|
class AppError(ValueError):
|
||||||
|
"""
|
||||||
|
Base class for app-level, client-safe errors.
|
||||||
|
Behaves like ValueError so existing except ValueError: still works.
|
||||||
|
"""
|
||||||
|
status_code: int = 400
|
||||||
|
|
||||||
|
def __init__(self, message, *, status_code: int | None = None):
|
||||||
|
# Support a single message or a list/tuple of messages
|
||||||
|
if isinstance(message, (list, tuple, set)):
|
||||||
|
self.messages = [str(m) for m in message]
|
||||||
|
msg = self.messages[0] if self.messages else ""
|
||||||
|
else:
|
||||||
|
self.messages = [str(message)]
|
||||||
|
msg = str(message)
|
||||||
|
|
||||||
|
super().__init__(msg)
|
||||||
|
|
||||||
|
if status_code is not None:
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def errors(app):
|
||||||
|
def _info(e):
|
||||||
|
return {
|
||||||
|
"exception": e,
|
||||||
|
"method": request.method,
|
||||||
|
"url": str(request.url),
|
||||||
|
"base_url": str(request.base_url),
|
||||||
|
"root_path": request.root_path,
|
||||||
|
"path": request.path,
|
||||||
|
"full_path": request.full_path,
|
||||||
|
"endpoint": request.endpoint,
|
||||||
|
"url_rule": str(request.url_rule) if request.url_rule else None,
|
||||||
|
"headers": {k: v for k, v in request.headers.items()
|
||||||
|
if k.lower().startswith("x-forwarded") or k in ("Host",)},
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
async def not_found(e):
|
||||||
|
current_app.logger.warning("404 %s", _info(e))
|
||||||
|
if hx_fragment_request():
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/hx/_.html",
|
||||||
|
errnum='404'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/_.html",
|
||||||
|
errnum='404',
|
||||||
|
)
|
||||||
|
|
||||||
|
return await make_response(html, 404)
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
async def not_allowed(e):
|
||||||
|
current_app.logger.warning("403 %s", _info(e))
|
||||||
|
if hx_fragment_request():
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/hx/_.html",
|
||||||
|
errnum='403'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/_.html",
|
||||||
|
errnum='403',
|
||||||
|
)
|
||||||
|
|
||||||
|
return await make_response(html, 403)
|
||||||
|
|
||||||
|
@app.errorhandler(AppError)
|
||||||
|
async def app_error(e: AppError):
|
||||||
|
# App-level, client-safe errors
|
||||||
|
current_app.logger.info("AppError %s", _info(e))
|
||||||
|
status = getattr(e, "status_code", 400)
|
||||||
|
messages = getattr(e, "messages", [str(e)])
|
||||||
|
|
||||||
|
if request.headers.get("HX-Request") == "true":
|
||||||
|
# Build a little styled <ul><li>...</li></ul> snippet
|
||||||
|
lis = "".join(
|
||||||
|
f"<li>{escape(m)}</li>"
|
||||||
|
for m in messages if m
|
||||||
|
)
|
||||||
|
html = (
|
||||||
|
"<ul class='list-disc pl-5 space-y-1 text-sm text-red-600'>"
|
||||||
|
f"{lis}"
|
||||||
|
"</ul>"
|
||||||
|
)
|
||||||
|
return await make_response(html, status)
|
||||||
|
|
||||||
|
# Non-HTMX: show a nicer page with error messages
|
||||||
|
html = await render_template(
|
||||||
|
"_types/root/exceptions/app_error.html",
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
return await make_response(html, status)
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
async def error(e):
|
||||||
|
current_app.logger.exception("Exception %s", _info(e))
|
||||||
|
|
||||||
|
status = 500
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
status = e.code or 500
|
||||||
|
|
||||||
|
if request.headers.get("HX-Request") == "true":
|
||||||
|
# Generic message for unexpected/untrusted errors
|
||||||
|
return await make_response(
|
||||||
|
"Something went wrong. Please try again.",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = await render_template("_types/root/exceptions/error.html")
|
||||||
|
return await make_response(html, status)
|
||||||
17
browser/app/filters/__init__.py
Normal file
17
browser/app/filters/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
def register(app):
|
||||||
|
from .highlight import highlight
|
||||||
|
app.jinja_env.filters["highlight"] = highlight
|
||||||
|
|
||||||
|
from .qs import register as qs
|
||||||
|
from .url_join import register as url_join
|
||||||
|
from .combine import register as combine
|
||||||
|
from .currency import register as currency
|
||||||
|
from .truncate import register as truncate
|
||||||
|
from .getattr import register as getattr
|
||||||
|
|
||||||
|
qs(app)
|
||||||
|
url_join(app)
|
||||||
|
combine(app)
|
||||||
|
currency(app)
|
||||||
|
getattr(app)
|
||||||
|
# truncate(app)
|
||||||
25
browser/app/filters/combine.py
Normal file
25
browser/app/filters/combine.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
def _deep_merge(dst: dict, src: Mapping) -> dict:
|
||||||
|
out = dict(dst)
|
||||||
|
for k, v in src.items():
|
||||||
|
if isinstance(v, Mapping) and isinstance(out.get(k), Mapping):
|
||||||
|
out[k] = _deep_merge(out[k], v) # type: ignore[arg-type]
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
def register(app):
|
||||||
|
@app.template_filter("combine")
|
||||||
|
def combine_filter(a: Any, b: Any, deep: bool = False, drop_none: bool = False) -> Any:
|
||||||
|
"""
|
||||||
|
Jinja filter: merge two dict-like objects.
|
||||||
|
|
||||||
|
- Non-dict inputs: returns `a` unchanged.
|
||||||
|
- If drop_none=True, keys in `b` with value None are ignored.
|
||||||
|
- If deep=True, nested dicts are merged recursively.
|
||||||
|
"""
|
||||||
|
if not isinstance(a, Mapping) or not isinstance(b, Mapping):
|
||||||
|
return a
|
||||||
|
b2 = {k: v for k, v in b.items() if not (drop_none and v is None)}
|
||||||
|
return _deep_merge(a, b2) if deep else {**a, **b2}
|
||||||
12
browser/app/filters/currency.py
Normal file
12
browser/app/filters/currency.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
@app.template_filter("currency")
|
||||||
|
def currency_filter(value, code="GBP"):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
# ensure decimal-ish
|
||||||
|
if isinstance(value, float):
|
||||||
|
value = Decimal(str(value))
|
||||||
|
symbol = "£" if code == "GBP" else code
|
||||||
|
return f"{symbol}{value:.2f}"
|
||||||
6
browser/app/filters/getattr.py
Normal file
6
browser/app/filters/getattr.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
def register(app):
|
||||||
|
@app.template_filter("getattr")
|
||||||
|
def jinja_getattr(obj, name, default=None):
|
||||||
|
# Safe getattr: returns default if the attribute is missing
|
||||||
|
return getattr(obj, name, default)
|
||||||
21
browser/app/filters/highlight.py
Normal file
21
browser/app/filters/highlight.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# ---------- misc helpers / filters ----------
|
||||||
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
|
def highlight(text: str, needle: str, cls: str = "bg-yellow-200 rounded") -> Markup:
|
||||||
|
"""
|
||||||
|
Wraps case-insensitive matches of `needle` inside <mark class="...">.
|
||||||
|
Escapes everything safely.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
if not text or not needle:
|
||||||
|
return Markup(escape(text or ""))
|
||||||
|
|
||||||
|
pattern = re.compile(re.escape(needle), re.IGNORECASE)
|
||||||
|
|
||||||
|
def repl(m: re.Match) -> str:
|
||||||
|
return f'<mark class="{escape(cls)}">{escape(m.group(0))}</mark>'
|
||||||
|
|
||||||
|
esc = escape(text)
|
||||||
|
result = pattern.sub(lambda m: Markup(repl(m)), esc)
|
||||||
|
return Markup(result)
|
||||||
|
|
||||||
13
browser/app/filters/qs.py
Normal file
13
browser/app/filters/qs.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from typing import Dict
|
||||||
|
from quart import g
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
@app.template_filter("qs")
|
||||||
|
def qs_filter(dict: Dict):
|
||||||
|
if getattr(g, "makeqs_factory", False):
|
||||||
|
q= g.makeqs_factory()(
|
||||||
|
**dict,
|
||||||
|
)
|
||||||
|
return q
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
78
browser/app/filters/qs_base.py
Normal file
78
browser/app/filters/qs_base.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Shared query-string primitives used by blog, market, and order qs modules.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# Sentinel meaning "leave value as-is" (used as default arg in makeqs)
|
||||||
|
KEEP = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _iterify(x):
|
||||||
|
"""Normalize *x* to a list: None → [], scalar → [scalar], iterable → as-is."""
|
||||||
|
if x is None:
|
||||||
|
return []
|
||||||
|
if isinstance(x, (list, tuple, set)):
|
||||||
|
return x
|
||||||
|
return [x]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
"""Strip + lowercase — used for case-insensitive filter dedup."""
|
||||||
|
return s.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def make_filter_set(
|
||||||
|
base: list[str],
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
clear_filters: bool,
|
||||||
|
*,
|
||||||
|
single_select: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build a deduplicated, sorted filter list.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
base : list[str]
|
||||||
|
Current filter values.
|
||||||
|
add : str | list | None
|
||||||
|
Value(s) to add.
|
||||||
|
remove : str | list | None
|
||||||
|
Value(s) to remove.
|
||||||
|
clear_filters : bool
|
||||||
|
If True, start from empty instead of *base*.
|
||||||
|
single_select : bool
|
||||||
|
If True, *add* **replaces** the list (blog tags/authors).
|
||||||
|
If False, *add* is **appended** (market brands/stickers/labels).
|
||||||
|
"""
|
||||||
|
add_list = [s for s in _iterify(add) if s is not None]
|
||||||
|
|
||||||
|
if single_select:
|
||||||
|
# Blog-style: adding replaces the entire set
|
||||||
|
if add_list:
|
||||||
|
table = {_norm(s): s for s in add_list}
|
||||||
|
else:
|
||||||
|
table = {_norm(s): s for s in base if not clear_filters}
|
||||||
|
else:
|
||||||
|
# Market-style: adding appends to the existing set
|
||||||
|
table = {_norm(s): s for s in base if not clear_filters}
|
||||||
|
for s in add_list:
|
||||||
|
k = _norm(s)
|
||||||
|
if k not in table:
|
||||||
|
table[k] = s
|
||||||
|
|
||||||
|
for s in _iterify(remove):
|
||||||
|
if s is None:
|
||||||
|
continue
|
||||||
|
table.pop(_norm(s), None)
|
||||||
|
|
||||||
|
return [table[k] for k in sorted(table)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_qs(params: list[tuple[str, str]], *, leading_q: bool = True) -> str:
|
||||||
|
"""URL-encode *params* and optionally prepend ``?``."""
|
||||||
|
qs = urlencode(params, doseq=True)
|
||||||
|
return ("?" + qs) if (qs and leading_q) else qs
|
||||||
33
browser/app/filters/query_types.py
Normal file
33
browser/app/filters/query_types.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
NamedTuple types returned by each blueprint's ``decode()`` function.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
class BlogQuery(NamedTuple):
|
||||||
|
page: int
|
||||||
|
search: str | None
|
||||||
|
sort: str | None
|
||||||
|
selected_tags: tuple[str, ...]
|
||||||
|
selected_authors: tuple[str, ...]
|
||||||
|
liked: str | None
|
||||||
|
view: str | None
|
||||||
|
drafts: str | None
|
||||||
|
selected_groups: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class MarketQuery(NamedTuple):
|
||||||
|
page: int
|
||||||
|
search: str | None
|
||||||
|
sort: str | None
|
||||||
|
selected_brands: tuple[str, ...]
|
||||||
|
selected_stickers: tuple[str, ...]
|
||||||
|
selected_labels: tuple[str, ...]
|
||||||
|
liked: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderQuery(NamedTuple):
|
||||||
|
page: int
|
||||||
|
search: str | None
|
||||||
22
browser/app/filters/truncate.py
Normal file
22
browser/app/filters/truncate.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
@app.template_filter("truncate")
|
||||||
|
def truncate(text, max_length=100):
|
||||||
|
"""
|
||||||
|
Truncate text to max_length characters and add an ellipsis character (…)
|
||||||
|
if it was longer.
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = str(text)
|
||||||
|
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Leave space for the ellipsis itself
|
||||||
|
if max_length <= 1:
|
||||||
|
return "…"
|
||||||
|
|
||||||
|
return text[:max_length - 1] + "…"
|
||||||
19
browser/app/filters/url_join.py
Normal file
19
browser/app/filters/url_join.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from typing import Iterable, Union
|
||||||
|
|
||||||
|
from shared.utils import join_url, host_url, _join_url_parts, route_prefix
|
||||||
|
|
||||||
|
|
||||||
|
# --- Register as a Jinja filter (Quart / Flask) ---
|
||||||
|
def register(app):
|
||||||
|
@app.template_filter("urljoin")
|
||||||
|
def urljoin_filter(value: Union[str, Iterable[str]]):
|
||||||
|
return join_url(value)
|
||||||
|
@app.template_filter("urlhost")
|
||||||
|
def urlhost_filter(value: Union[str, Iterable[str]]):
|
||||||
|
return host_url(value)
|
||||||
|
@app.template_filter("urlhost_no_slash")
|
||||||
|
def urlhost_no_slash_filter(value: Union[str, Iterable[str]]):
|
||||||
|
return host_url(value, True)
|
||||||
|
@app.template_filter("host")
|
||||||
|
def host_filter(value: str):
|
||||||
|
return _join_url_parts([route_prefix(), value])
|
||||||
58
browser/app/middleware.py
Normal file
58
browser/app/middleware.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
def register(app):
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
def _decode_headers(scope) -> dict[str, str]:
|
||||||
|
out = {}
|
||||||
|
for k, v in scope.get("headers", []):
|
||||||
|
try:
|
||||||
|
out[k.decode("latin1")] = v.decode("latin1")
|
||||||
|
except Exception:
|
||||||
|
out[repr(k)] = repr(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _safe(obj: Any):
|
||||||
|
# make scope json-serialisable; fall back to repr()
|
||||||
|
try:
|
||||||
|
json.dumps(obj)
|
||||||
|
return obj
|
||||||
|
except Exception:
|
||||||
|
return repr(obj)
|
||||||
|
|
||||||
|
class ScopeDumpMiddleware:
|
||||||
|
def __init__(self, app, *, log_bodies: bool = False):
|
||||||
|
self.app = app
|
||||||
|
self.log_bodies = log_bodies # keep False; bodies aren't needed for routing
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] in ("http", "websocket"):
|
||||||
|
# Build a compact view of keys relevant to routing
|
||||||
|
scope_view = {
|
||||||
|
"type": scope.get("type"),
|
||||||
|
"asgi": scope.get("asgi"),
|
||||||
|
"http_version": scope.get("http_version"),
|
||||||
|
"scheme": scope.get("scheme"),
|
||||||
|
"method": scope.get("method"),
|
||||||
|
"server": scope.get("server"),
|
||||||
|
"client": scope.get("client"),
|
||||||
|
"root_path": scope.get("root_path"),
|
||||||
|
"path": scope.get("path"),
|
||||||
|
"raw_path": scope.get("raw_path").decode("latin1") if scope.get("raw_path") else None,
|
||||||
|
"query_string": scope.get("query_string", b"").decode("latin1"),
|
||||||
|
"headers": _decode_headers(scope),
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n=== ASGI SCOPE (routing) ===")
|
||||||
|
print(json.dumps({_safe(k): _safe(v) for k, v in scope_view.items()}, indent=2))
|
||||||
|
print("=== END SCOPE ===\n", flush=True)
|
||||||
|
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
# wrap LAST so you see what hits Quart
|
||||||
|
#app.asgi_app = ScopeDumpMiddleware(app.asgi_app)
|
||||||
|
|
||||||
|
|
||||||
|
from hypercorn.middleware import ProxyFixMiddleware
|
||||||
|
# trust a single proxy hop; use legacy X-Forwarded-* headers
|
||||||
|
app.asgi_app = ProxyFixMiddleware(app.asgi_app, mode="legacy", trusted_hops=1)
|
||||||
1
browser/app/payments/__init__.py
Normal file
1
browser/app/payments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
121
browser/app/payments/sumup.py
Normal file
121
browser/app/payments/sumup.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from quart import current_app
|
||||||
|
|
||||||
|
from shared.config import config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from cart.models.order import Order
|
||||||
|
|
||||||
|
SUMUP_BASE_URL = "https://api.sumup.com/v0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def _sumup_settings() -> Dict[str, str]:
|
||||||
|
cfg = config()
|
||||||
|
sumup_cfg = cfg.get("sumup", {}) or {}
|
||||||
|
api_key_env = sumup_cfg.get("api_key_env", "SUMUP_API_KEY")
|
||||||
|
api_key = os.getenv(api_key_env)
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError(f"Missing SumUp API key in environment variable {api_key_env}")
|
||||||
|
|
||||||
|
merchant_code = sumup_cfg.get("merchant_code")
|
||||||
|
prefix = sumup_cfg.get("checkout_prefix", "")
|
||||||
|
if not merchant_code:
|
||||||
|
raise RuntimeError("Missing 'sumup.merchant_code' in app-config.yaml")
|
||||||
|
|
||||||
|
currency = sumup_cfg.get("currency", "GBP")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_key": api_key,
|
||||||
|
"merchant_code": merchant_code,
|
||||||
|
"currency": currency,
|
||||||
|
"checkout_reference_prefix": prefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_checkout(
|
||||||
|
order: Order,
|
||||||
|
redirect_url: str,
|
||||||
|
webhook_url: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
settings = _sumup_settings()
|
||||||
|
# Use stored reference if present, otherwise build it
|
||||||
|
checkout_reference = order.sumup_reference or f"{settings['checkout_reference_prefix']}{order.id}"
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"checkout_reference": checkout_reference,
|
||||||
|
"amount": float(order.total_amount),
|
||||||
|
"currency": settings["currency"],
|
||||||
|
"merchant_code": settings["merchant_code"],
|
||||||
|
"description": description or f"Order {order.id} at {current_app.config.get('APP_TITLE', 'Rose Ash')}",
|
||||||
|
"return_url": webhook_url or redirect_url,
|
||||||
|
"redirect_url": redirect_url,
|
||||||
|
"hosted_checkout": {"enabled": True},
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings['api_key']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: log for debugging
|
||||||
|
current_app.logger.info(
|
||||||
|
"Creating SumUp checkout %s for Order %s amount %.2f",
|
||||||
|
checkout_reference,
|
||||||
|
order.id,
|
||||||
|
float(order.total_amount),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(f"{SUMUP_BASE_URL}/checkouts", json=payload, headers=headers)
|
||||||
|
|
||||||
|
if resp.status_code == 409:
|
||||||
|
# Duplicate checkout — retrieve the existing one by reference
|
||||||
|
current_app.logger.warning(
|
||||||
|
"SumUp duplicate checkout for ref %s order %s, fetching existing",
|
||||||
|
checkout_reference,
|
||||||
|
order.id,
|
||||||
|
)
|
||||||
|
list_resp = await client.get(
|
||||||
|
f"{SUMUP_BASE_URL}/checkouts",
|
||||||
|
params={"checkout_reference": checkout_reference},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
list_resp.raise_for_status()
|
||||||
|
items = list_resp.json()
|
||||||
|
if isinstance(items, list) and items:
|
||||||
|
return items[0]
|
||||||
|
if isinstance(items, dict) and items.get("items"):
|
||||||
|
return items["items"][0]
|
||||||
|
# Fallback: re-raise original error
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
current_app.logger.error(
|
||||||
|
"SumUp checkout error for ref %s order %s: %s",
|
||||||
|
checkout_reference,
|
||||||
|
order.id,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def get_checkout(checkout_id: str) -> Dict[str, Any]:
|
||||||
|
"""Fetch checkout status/details from SumUp."""
|
||||||
|
settings = _sumup_settings()
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings['api_key']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(f"{SUMUP_BASE_URL}/checkouts/{checkout_id}", headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
346
browser/app/redis_cacher.py
Normal file
346
browser/app/redis_cacher.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Optional, Literal
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
Quart,
|
||||||
|
request,
|
||||||
|
Response,
|
||||||
|
g,
|
||||||
|
current_app,
|
||||||
|
)
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
|
||||||
|
Scope = Literal["user", "global", "anon"]
|
||||||
|
TagScope = Literal["all", "user"] # for clear_cache
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Redis setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register(app: Quart) -> None:
|
||||||
|
@app.before_serving
|
||||||
|
async def setup_redis() -> None:
|
||||||
|
if app.config["REDIS_URL"] and app.config["REDIS_URL"] != 'no':
|
||||||
|
app.redis = aioredis.Redis.from_url(
|
||||||
|
app.config["REDIS_URL"],
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=False, # store bytes
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app.redis = False
|
||||||
|
|
||||||
|
@app.after_serving
|
||||||
|
async def close_redis() -> None:
|
||||||
|
if app.redis:
|
||||||
|
await app.redis.close()
|
||||||
|
# optional: await app.redis.connection_pool.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis():
|
||||||
|
return current_app.redis
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Key helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_user_id() -> str:
|
||||||
|
"""
|
||||||
|
Returns a string id or 'anon'.
|
||||||
|
Adjust based on your auth system.
|
||||||
|
"""
|
||||||
|
user = getattr(g, "user", None)
|
||||||
|
if user:
|
||||||
|
return str(user.id)
|
||||||
|
return "anon"
|
||||||
|
|
||||||
|
|
||||||
|
def make_cache_key(cache_user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Build a cache key for this (user/global/anon) + path + query + HTMX status.
|
||||||
|
|
||||||
|
HTMX requests and normal requests get different cache keys because they
|
||||||
|
return different content (partials vs full pages).
|
||||||
|
|
||||||
|
Keys are namespaced by app name (from CACHE_APP_PREFIX) to avoid
|
||||||
|
collisions between apps that may share the same paths.
|
||||||
|
"""
|
||||||
|
app_prefix = current_app.config.get("CACHE_APP_PREFIX", "app")
|
||||||
|
path = request.path
|
||||||
|
qs = request.query_string.decode() if request.query_string else ""
|
||||||
|
|
||||||
|
# Check if this is an HTMX request
|
||||||
|
is_htmx = request.headers.get("HX-Request", "").lower() == "true"
|
||||||
|
htmx_suffix = ":htmx" if is_htmx else ""
|
||||||
|
|
||||||
|
if qs:
|
||||||
|
return f"cache:{app_prefix}:page:{cache_user_id}:{path}?{qs}{htmx_suffix}"
|
||||||
|
else:
|
||||||
|
return f"cache:{app_prefix}:page:{cache_user_id}:{path}{htmx_suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def user_set_key(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Redis set that tracks all cache keys for a given user id.
|
||||||
|
Only used when scope='user'.
|
||||||
|
"""
|
||||||
|
return f"cache:user:{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def tag_set_key(tag: str) -> str:
|
||||||
|
"""
|
||||||
|
Redis set that tracks all cache keys associated with a tag
|
||||||
|
(across all scopes/users).
|
||||||
|
"""
|
||||||
|
return f"cache:tag:{tag}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invalidation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def invalidate_user_cache(user_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Delete all cached pages for a specific user (scope='user' caches).
|
||||||
|
"""
|
||||||
|
r = get_redis()
|
||||||
|
if r:
|
||||||
|
s_key = user_set_key(user_id)
|
||||||
|
keys = await r.smembers(s_key) # set of bytes
|
||||||
|
if keys:
|
||||||
|
await r.delete(*keys)
|
||||||
|
await r.delete(s_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_tag_cache(tag: str) -> None:
|
||||||
|
"""
|
||||||
|
Delete all cached pages associated with this tag, for all users/scopes.
|
||||||
|
"""
|
||||||
|
r = get_redis()
|
||||||
|
if r:
|
||||||
|
t_key = tag_set_key(tag)
|
||||||
|
keys = await r.smembers(t_key) # set of bytes
|
||||||
|
if keys:
|
||||||
|
await r.delete(*keys)
|
||||||
|
await r.delete(t_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_tag_cache_for_user(tag: str, cache_uid: str) -> None:
|
||||||
|
r = get_redis()
|
||||||
|
if not r:
|
||||||
|
return
|
||||||
|
|
||||||
|
t_key = tag_set_key(tag)
|
||||||
|
keys = await r.smembers(t_key) # set of bytes
|
||||||
|
if not keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = f"cache:page:{cache_uid}:".encode("utf-8")
|
||||||
|
|
||||||
|
# Filter keys belonging to this cache_uid only
|
||||||
|
to_delete = [k for k in keys if k.startswith(prefix)]
|
||||||
|
if not to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete those page entries
|
||||||
|
await r.delete(*to_delete)
|
||||||
|
# Remove them from the tag set (leave other users' keys intact)
|
||||||
|
await r.srem(t_key, *to_delete)
|
||||||
|
|
||||||
|
async def invalidate_tag_cache_for_current_user(tag: str) -> None:
|
||||||
|
"""
|
||||||
|
Convenience helper: delete tag cache for the current user_id (scope='user').
|
||||||
|
"""
|
||||||
|
uid = get_user_id()
|
||||||
|
await invalidate_tag_cache_for_user(tag, uid)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache decorator for GET
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def cache_page(
|
||||||
|
ttl: int = 0,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
scope: Scope = "user",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cache GET responses in Redis.
|
||||||
|
|
||||||
|
ttl:
|
||||||
|
Seconds to keep the cache. 0 = no expiry.
|
||||||
|
tag:
|
||||||
|
Optional tag name used for bulk invalidation via invalidate_tag_cache().
|
||||||
|
scope:
|
||||||
|
"user" → cache per-user (includes 'anon'), tracked in cache:user:{id}
|
||||||
|
"global" → single cache shared by everyone (no per-user tracking)
|
||||||
|
"anon" → cache only for anonymous users; logged-in users bypass cache
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
r = get_redis()
|
||||||
|
|
||||||
|
if not r or request.method != "GET":
|
||||||
|
return await view(*args, **kwargs)
|
||||||
|
uid = get_user_id()
|
||||||
|
|
||||||
|
# Decide who the cache key is keyed on
|
||||||
|
if scope == "global":
|
||||||
|
cache_uid = "global"
|
||||||
|
elif scope == "anon":
|
||||||
|
# Only cache for anonymous users
|
||||||
|
if uid != "anon":
|
||||||
|
return await view(*args, **kwargs)
|
||||||
|
cache_uid = "anon"
|
||||||
|
else: # scope == "user"
|
||||||
|
cache_uid = uid
|
||||||
|
|
||||||
|
key = make_cache_key(cache_uid)
|
||||||
|
|
||||||
|
cached = await r.hgetall(key)
|
||||||
|
if cached:
|
||||||
|
body = cached[b"body"]
|
||||||
|
status = int(cached[b"status"].decode())
|
||||||
|
content_type = cached.get(b"content_type", b"text/html").decode()
|
||||||
|
return Response(body, status=status, content_type=content_type)
|
||||||
|
|
||||||
|
# Not cached, call the view
|
||||||
|
resp = await view(*args, **kwargs)
|
||||||
|
|
||||||
|
# Normalise: if the view returned a string/bytes, wrap it
|
||||||
|
if not isinstance(resp, Response):
|
||||||
|
resp = Response(resp, content_type="text/html")
|
||||||
|
|
||||||
|
# Only cache successful responses
|
||||||
|
if resp.status_code == 200:
|
||||||
|
body = await resp.get_data() # bytes
|
||||||
|
|
||||||
|
pipe = r.pipeline()
|
||||||
|
pipe.hset(
|
||||||
|
key,
|
||||||
|
mapping={
|
||||||
|
"body": body,
|
||||||
|
"status": str(resp.status_code),
|
||||||
|
"content_type": resp.content_type or "text/html",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ttl:
|
||||||
|
pipe.expire(key, ttl)
|
||||||
|
|
||||||
|
# Track per-user keys only when scope='user'
|
||||||
|
if scope == "user":
|
||||||
|
pipe.sadd(user_set_key(cache_uid), key)
|
||||||
|
|
||||||
|
# Track per-tag keys (all scopes)
|
||||||
|
if tag:
|
||||||
|
pipe.sadd(tag_set_key(tag), key)
|
||||||
|
|
||||||
|
await pipe.execute()
|
||||||
|
|
||||||
|
resp.set_data(body)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Clear cache decorator for POST (or any method)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def clear_cache(
|
||||||
|
*,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
tag_scope: TagScope = "all",
|
||||||
|
clear_user: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decorator for routes that should clear cache after they run.
|
||||||
|
|
||||||
|
Use on POST/PUT/PATCH/DELETE handlers.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
tag:
|
||||||
|
If set, will clear caches for this tag.
|
||||||
|
tag_scope:
|
||||||
|
"all" → invalidate_tag_cache(tag) (all users/scopes)
|
||||||
|
"user" → invalidate_tag_cache_for_current_user(tag)
|
||||||
|
clear_user:
|
||||||
|
If True, also run invalidate_user_cache(current_user_id).
|
||||||
|
|
||||||
|
Typical usage:
|
||||||
|
|
||||||
|
@bp.post("/posts/<slug>/edit")
|
||||||
|
@clear_cache(tag="post.post_detail", tag_scope="all")
|
||||||
|
async def edit_post(slug):
|
||||||
|
...
|
||||||
|
|
||||||
|
@bp.post("/prefs")
|
||||||
|
@clear_cache(tag="dashboard", tag_scope="user", clear_user=True)
|
||||||
|
async def update_prefs():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Run the view first
|
||||||
|
resp = await view(*args, **kwargs)
|
||||||
|
if get_redis():
|
||||||
|
|
||||||
|
# Only clear cache if the view succeeded (2xx)
|
||||||
|
status = getattr(resp, "status_code", None)
|
||||||
|
if status is None:
|
||||||
|
# Non-Response return (string, dict) -> treat as success
|
||||||
|
success = True
|
||||||
|
else:
|
||||||
|
success = 200 <= status < 300
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Perform invalidations
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
if clear_user:
|
||||||
|
uid = get_user_id()
|
||||||
|
tasks.append(invalidate_user_cache(uid))
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
if tag_scope == "all":
|
||||||
|
tasks.append(invalidate_tag_cache(tag))
|
||||||
|
else: # tag_scope == "user"
|
||||||
|
tasks.append(invalidate_tag_cache_for_current_user(tag))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
# Run them concurrently
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
async def clear_all_cache(prefix: str = "cache:") -> None:
|
||||||
|
r = get_redis()
|
||||||
|
if not r:
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = 0
|
||||||
|
pattern = f"{prefix}*"
|
||||||
|
while True:
|
||||||
|
cursor, keys = await r.scan(cursor=cursor, match=pattern, count=500)
|
||||||
|
if keys:
|
||||||
|
await r.delete(*keys)
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
12
browser/app/utils/__init__.py
Normal file
12
browser/app/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from .parse import (
|
||||||
|
parse_time,
|
||||||
|
parse_cost,
|
||||||
|
parse_dt
|
||||||
|
)
|
||||||
|
from .utils import (
|
||||||
|
current_route_relative_path,
|
||||||
|
current_url_without_page,
|
||||||
|
vary,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .utc import utcnow
|
||||||
46
browser/app/utils/htmx.py
Normal file
46
browser/app/utils/htmx.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""HTMX utilities for detecting and handling HTMX requests."""
|
||||||
|
|
||||||
|
from quart import request
|
||||||
|
|
||||||
|
|
||||||
|
def is_htmx_request() -> bool:
|
||||||
|
"""
|
||||||
|
Check if the current request is an HTMX request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if HX-Request header is present and true
|
||||||
|
"""
|
||||||
|
return request.headers.get("HX-Request", "").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def get_htmx_target() -> str | None:
|
||||||
|
"""
|
||||||
|
Get the target element ID from HTMX request headers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: Target element ID or None
|
||||||
|
"""
|
||||||
|
return request.headers.get("HX-Target")
|
||||||
|
|
||||||
|
|
||||||
|
def get_htmx_trigger() -> str | None:
|
||||||
|
"""
|
||||||
|
Get the trigger element ID from HTMX request headers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: Trigger element ID or None
|
||||||
|
"""
|
||||||
|
return request.headers.get("HX-Trigger")
|
||||||
|
|
||||||
|
|
||||||
|
def should_return_fragment() -> bool:
|
||||||
|
"""
|
||||||
|
Determine if we should return a fragment vs full page.
|
||||||
|
|
||||||
|
For HTMX requests, return fragment.
|
||||||
|
For normal requests, return full page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if fragment should be returned
|
||||||
|
"""
|
||||||
|
return is_htmx_request()
|
||||||
36
browser/app/utils/parse.py
Normal file
36
browser/app/utils/parse.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
def parse_time(val: str | None):
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
h,m = val.split(':', 1)
|
||||||
|
from datetime import time
|
||||||
|
return time(int(h), int(m))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_cost(val: str | None):
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
dt = datetime.fromisoformat(val)
|
||||||
|
# make TZ-aware (assume local if naive; convert to UTC)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
def parse_dt(val: str | None) -> datetime | None:
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
dt = datetime.fromisoformat(val)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
6
browser/app/utils/utc.py
Normal file
6
browser/app/utils/utc.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
51
browser/app/utils/utils.py
Normal file
51
browser/app/utils/utils.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from quart import (
|
||||||
|
Response,
|
||||||
|
request,
|
||||||
|
g,
|
||||||
|
)
|
||||||
|
from shared.utils import host_url
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
def current_route_relative_path() -> str:
|
||||||
|
"""
|
||||||
|
Returns the current request path relative to the app's mount point (script_root).
|
||||||
|
"""
|
||||||
|
|
||||||
|
(request.script_root or "").rstrip("/")
|
||||||
|
path = request.path # excludes query string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if g.root and path.startswith(f"/{g.root}"):
|
||||||
|
rel = path[len(g.root+1):]
|
||||||
|
return rel if rel.startswith("/") else "/" + rel
|
||||||
|
return path # app at /
|
||||||
|
|
||||||
|
|
||||||
|
def current_url_without_page() -> str:
|
||||||
|
"""
|
||||||
|
Build current URL (host+path+qs) but with ?page= removed.
|
||||||
|
Used for Hx-Push-Url.
|
||||||
|
"""
|
||||||
|
base = host_url(current_route_relative_path())
|
||||||
|
|
||||||
|
params = request.args.to_dict(flat=False) # keep multivals
|
||||||
|
params.pop("page", None)
|
||||||
|
qs = urlencode(params, doseq=True)
|
||||||
|
|
||||||
|
return f"{base}?{qs}" if qs else base
|
||||||
|
|
||||||
|
def vary(resp: Response) -> Response:
|
||||||
|
"""
|
||||||
|
Ensure caches/CDNs vary on HX headers so htmx/non-htmx versions don't get mixed.
|
||||||
|
"""
|
||||||
|
v = resp.headers.get("Vary", "")
|
||||||
|
parts = [p.strip() for p in v.split(",") if p.strip()]
|
||||||
|
for h in ("HX-Request", "X-Origin"):
|
||||||
|
if h not in parts:
|
||||||
|
parts.append(h)
|
||||||
|
if parts:
|
||||||
|
resp.headers["Vary"] = ", ".join(parts)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
33
browser/templates/_oob_elements.html
Normal file
33
browser/templates/_oob_elements.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends oob.oob_extends %}
|
||||||
|
|
||||||
|
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||||
|
|
||||||
|
{# Import shared OOB macros #}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header(
|
||||||
|
oob.parent_id,
|
||||||
|
oob.child_id,
|
||||||
|
oob.header,
|
||||||
|
)}}
|
||||||
|
|
||||||
|
{% from oob.parent_header import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{# Mobile menu - from market/index.html _main_mobile_menu block #}
|
||||||
|
{% set mobile_nav %}
|
||||||
|
{% include oob.nav %}
|
||||||
|
{% endset %}
|
||||||
|
{{ mobile_menu(mobile_nav) }}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include oob.main %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
49
browser/templates/_types/auth/_main_panel.html
Normal file
49
browser/templates/_types/auth/_main_panel.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<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>
|
||||||
9
browser/templates/_types/auth/_nav.html
Normal file
9
browser/templates/_types/auth/_nav.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% 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 %}
|
||||||
|
<div class="relative nav-group">
|
||||||
|
<a href="{{ cart_url('/orders/') }}" class="{{styles.nav_button}}">
|
||||||
|
orders
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
17
browser/templates/_types/auth/_newsletter_toggle.html
Normal file
17
browser/templates/_types/auth/_newsletter_toggle.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
46
browser/templates/_types/auth/_newsletters_panel.html
Normal file
46
browser/templates/_types/auth/_newsletters_panel.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<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>
|
||||||
29
browser/templates/_types/auth/_oob_elements.html
Normal file
29
browser/templates/_types/auth/_oob_elements.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||||
|
|
||||||
|
{# Import shared OOB macros #}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/auth/_nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include oob.main %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
33
browser/templates/_types/auth/check_email.html
Normal file
33
browser/templates/_types/auth/check_email.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% 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 %}
|
||||||
12
browser/templates/_types/auth/header/_header.html
Normal file
12
browser/templates/_types/auth/header/_header.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% 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 %}
|
||||||
18
browser/templates/_types/auth/index copy.html
Normal file
18
browser/templates/_types/auth/index copy.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% 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 %}
|
||||||
18
browser/templates/_types/auth/index.html
Normal file
18
browser/templates/_types/auth/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% 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 %}
|
||||||
46
browser/templates/_types/auth/login.html
Normal file
46
browser/templates/_types/auth/login.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% 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 %}
|
||||||
51
browser/templates/_types/blog/_action_buttons.html
Normal file
51
browser/templates/_types/blog/_action_buttons.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
|
||||||
|
<div class="flex flex-wrap gap-2 px-4 py-3">
|
||||||
|
{% if has_access('blog.new_post') %}
|
||||||
|
{% set new_href = url_for('blog.new_post')|host %}
|
||||||
|
<a
|
||||||
|
href="{{ new_href }}"
|
||||||
|
hx-get="{{ new_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||||
|
title="New Post"
|
||||||
|
>
|
||||||
|
<i class="fa fa-plus mr-1"></i> New Post
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if g.user and (draft_count or drafts) %}
|
||||||
|
{% if drafts %}
|
||||||
|
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ drafts_off_href }}"
|
||||||
|
hx-get="{{ drafts_off_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||||
|
title="Hide Drafts"
|
||||||
|
>
|
||||||
|
<i class="fa fa-file-text-o mr-1"></i> Drafts
|
||||||
|
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ drafts_on_href }}"
|
||||||
|
hx-get="{{ drafts_on_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||||
|
title="Show Drafts"
|
||||||
|
>
|
||||||
|
<i class="fa fa-file-text-o mr-1"></i> Drafts
|
||||||
|
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
110
browser/templates/_types/blog/_card.html
Normal file
110
browser/templates/_types/blog/_card.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{% import 'macros/stickers.html' as stick %}
|
||||||
|
<article class="border-b pb-6 last:border-b-0 relative">
|
||||||
|
{# ❤️ like button - OUTSIDE the link, aligned with image top #}
|
||||||
|
{% if g.user %}
|
||||||
|
<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">
|
||||||
|
{% set slug = post.slug %}
|
||||||
|
{% set liked = post.is_liked or False %}
|
||||||
|
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
|
||||||
|
{% set item_type = 'post' %}
|
||||||
|
{% include "_types/browse/like/button.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ _href }}"
|
||||||
|
hx-get="{{ _href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select ="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
aria-selected="{{ 'true' if _active else 'false' }}"
|
||||||
|
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||||
|
>
|
||||||
|
<header class="mb-2 text-center">
|
||||||
|
<h2 class="text-4xl font-bold text-stone-900">
|
||||||
|
{{ post.title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if post.status == "draft" %}
|
||||||
|
<div class="flex justify-center gap-2 mt-1">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
|
||||||
|
{% if post.publish_requested %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if post.updated_at %}
|
||||||
|
<p class="text-sm text-stone-500">
|
||||||
|
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% elif post.published_at %}
|
||||||
|
<p class="text-sm text-stone-500">
|
||||||
|
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if post.feature_image %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img
|
||||||
|
src="{{ post.feature_image }}"
|
||||||
|
alt=""
|
||||||
|
class="rounded-lg w-full object-cover"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if post.custom_excerpt %}
|
||||||
|
<p class="text-stone-700 text-lg leading-relaxed text-center">
|
||||||
|
{{ post.custom_excerpt }}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
{% if post.excerpt %}
|
||||||
|
<p class="text-stone-700 text-lg leading-relaxed text-center">
|
||||||
|
{{ post.excerpt }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# Associated Entries - Scrollable list #}
|
||||||
|
{% if post.associated_entries %}
|
||||||
|
<div class="mt-4 mb-2">
|
||||||
|
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
|
||||||
|
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
|
||||||
|
<div class="flex gap-2 px-2">
|
||||||
|
{% for entry in post.associated_entries %}
|
||||||
|
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar.slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||||
|
<a
|
||||||
|
href="{{ events_url(_entry_path) }}"
|
||||||
|
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
|
||||||
|
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
|
||||||
|
<div class="text-xs text-stone-600">
|
||||||
|
{{ entry.start_at.strftime('%a, %b %d') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-stone-500">
|
||||||
|
{{ entry.start_at.strftime('%H:%M') }}
|
||||||
|
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% include '_types/blog/_card/at_bar.html' %}
|
||||||
|
|
||||||
|
</article>
|
||||||
19
browser/templates/_types/blog/_card/at_bar.html
Normal file
19
browser/templates/_types/blog/_card/at_bar.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="flex flex-row justify-center gap-3">
|
||||||
|
{% if post.tags %}
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<div>in</div>
|
||||||
|
<ul class="flex flex-wrap gap-2 text-sm">
|
||||||
|
{% include '_types/blog/_card/tags.html' %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div></div>
|
||||||
|
{% if post.authors %}
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<div>by</div>
|
||||||
|
<ul class="flex flex-wrap gap-2 text-sm">
|
||||||
|
{% include '_types/blog/_card/authors.html' %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
21
browser/templates/_types/blog/_card/author.html
Normal file
21
browser/templates/_types/blog/_card/author.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% macro author(author) %}
|
||||||
|
{% if author %}
|
||||||
|
{% if author.profile_image %}
|
||||||
|
<img
|
||||||
|
src="{{ author.profile_image }}"
|
||||||
|
alt="{{ author.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div class="h-6 w-6"></div>
|
||||||
|
{# optional fallback circle with first letter
|
||||||
|
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||||
|
{{ author.name[:1] }}
|
||||||
|
</div> #}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
|
||||||
|
{{ author.name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
32
browser/templates/_types/blog/_card/authors.html
Normal file
32
browser/templates/_types/blog/_card/authors.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{# --- AUTHORS LIST STARTS HERE --- #}
|
||||||
|
{% if post.authors and post.authors|length %}
|
||||||
|
{% for a in post.authors %}
|
||||||
|
{% for author in authors if author.slug==a.slug %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
href="{{ { 'clear_filters': True, 'add_author': author.slug }|qs|host}}"
|
||||||
|
>
|
||||||
|
{% if author.profile_image %}
|
||||||
|
<img
|
||||||
|
src="{{ author.profile_image }}"
|
||||||
|
alt="{{ author.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
{# optional fallback circle with first letter #}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||||
|
{{ author.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ author.name }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- AUTHOR LIST ENDS HERE --- #}
|
||||||
19
browser/templates/_types/blog/_card/tag.html
Normal file
19
browser/templates/_types/blog/_card/tag.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% macro tag(tag) %}
|
||||||
|
{% if tag %}
|
||||||
|
{% if tag.feature_image %}
|
||||||
|
<img
|
||||||
|
src="{{ tag.feature_image }}"
|
||||||
|
alt="{{ tag.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||||
|
{{ tag.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
22
browser/templates/_types/blog/_card/tag_group.html
Normal file
22
browser/templates/_types/blog/_card/tag_group.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% macro tag_group(group) %}
|
||||||
|
{% if group %}
|
||||||
|
{% if group.feature_image %}
|
||||||
|
<img
|
||||||
|
src="{{ group.feature_image }}"
|
||||||
|
alt="{{ group.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||||
|
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
|
||||||
|
>
|
||||||
|
{{ group.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
|
||||||
|
{{ group.name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
17
browser/templates/_types/blog/_card/tags.html
Normal file
17
browser/templates/_types/blog/_card/tags.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% import '_types/blog/_card/tag.html' as dotag %}
|
||||||
|
{# --- TAG LIST STARTS HERE --- #}
|
||||||
|
{% if post.tags and post.tags|length %}
|
||||||
|
{% for t in post.tags %}
|
||||||
|
{% for tag in tags if tag.slug==t.slug %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
href="{{ { 'clear_filters': True, 'add_tag': tag.slug }|qs|host}}"
|
||||||
|
>
|
||||||
|
{{dotag.tag(tag)}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{# --- TAG LIST ENDS HERE --- #}
|
||||||
59
browser/templates/_types/blog/_card_tile.html
Normal file
59
browser/templates/_types/blog/_card_tile.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<article class="relative">
|
||||||
|
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ _href }}"
|
||||||
|
hx-get="{{ _href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select ="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
aria-selected="{{ 'true' if _active else 'false' }}"
|
||||||
|
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||||
|
>
|
||||||
|
{% if post.feature_image %}
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src="{{ post.feature_image }}"
|
||||||
|
alt=""
|
||||||
|
class="w-full aspect-video object-cover"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="p-3 text-center">
|
||||||
|
<h2 class="text-lg font-bold text-stone-900">
|
||||||
|
{{ post.title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if post.status == "draft" %}
|
||||||
|
<div class="flex justify-center gap-1 mt-1">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
|
||||||
|
{% if post.publish_requested %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if post.updated_at %}
|
||||||
|
<p class="text-sm text-stone-500">
|
||||||
|
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% elif post.published_at %}
|
||||||
|
<p class="text-sm text-stone-500">
|
||||||
|
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if post.custom_excerpt %}
|
||||||
|
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
|
||||||
|
{{ post.custom_excerpt }}
|
||||||
|
</p>
|
||||||
|
{% elif post.excerpt %}
|
||||||
|
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
|
||||||
|
{{ post.excerpt }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% include '_types/blog/_card/at_bar.html' %}
|
||||||
|
</article>
|
||||||
111
browser/templates/_types/blog/_cards.html
Normal file
111
browser/templates/_types/blog/_cards.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{% for post in posts %}
|
||||||
|
{% if view == 'tile' %}
|
||||||
|
{% include "_types/blog/_card_tile.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% include "_types/blog/_card.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if page < total_pages|int %}
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="sentinel-{{ page }}-m"
|
||||||
|
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
||||||
|
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
|
||||||
|
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
_="
|
||||||
|
init
|
||||||
|
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
|
||||||
|
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
|
||||||
|
|
||||||
|
on resize from window
|
||||||
|
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
|
||||||
|
|
||||||
|
on htmx:beforeRequest
|
||||||
|
if window.matchMedia('(min-width: 768px)').matches then halt end
|
||||||
|
add .hidden to .js-neterr in me
|
||||||
|
remove .hidden from .js-loading in me
|
||||||
|
remove .opacity-100 from me
|
||||||
|
add .opacity-0 to me
|
||||||
|
|
||||||
|
def backoff()
|
||||||
|
set ms to me.dataset.retryMs
|
||||||
|
if ms > 30000 then set ms to 30000 end
|
||||||
|
-- show big SVG panel & make sentinel visible
|
||||||
|
add .hidden to .js-loading in me
|
||||||
|
remove .hidden from .js-neterr in me
|
||||||
|
remove .opacity-0 from me
|
||||||
|
add .opacity-100 to me
|
||||||
|
wait ms ms
|
||||||
|
trigger sentinelmobile:retry
|
||||||
|
set ms to ms * 2
|
||||||
|
if ms > 30000 then set ms to 30000 end
|
||||||
|
set me.dataset.retryMs to ms
|
||||||
|
end
|
||||||
|
|
||||||
|
on htmx:sendError call backoff()
|
||||||
|
on htmx:responseError call backoff()
|
||||||
|
on htmx:timeout call backoff()
|
||||||
|
"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{% include "sentinel/mobile_content.html" %}
|
||||||
|
</div>
|
||||||
|
<!-- DESKTOP sentinel (custom scroll container) -->
|
||||||
|
<div
|
||||||
|
id="sentinel-{{ page }}-d"
|
||||||
|
class="hidden md:block h-4 opacity-0 pointer-events-none"
|
||||||
|
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
|
||||||
|
hx-trigger="intersect once delay:250ms, sentinel:retry"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
_="
|
||||||
|
init
|
||||||
|
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
|
||||||
|
|
||||||
|
on htmx:beforeRequest(event)
|
||||||
|
add .hidden to .js-neterr in me
|
||||||
|
remove .hidden from .js-loading in me
|
||||||
|
remove .opacity-100 from me
|
||||||
|
add .opacity-0 to me
|
||||||
|
|
||||||
|
set trig to null
|
||||||
|
if event.detail and event.detail.triggeringEvent then
|
||||||
|
set trig to event.detail.triggeringEvent
|
||||||
|
end
|
||||||
|
if trig and trig.type is 'intersect'
|
||||||
|
set scroller to the closest .js-grid-viewport
|
||||||
|
if scroller is null then halt end
|
||||||
|
if scroller.scrollTop < 20 then halt end
|
||||||
|
end
|
||||||
|
|
||||||
|
def backoff()
|
||||||
|
set ms to me.dataset.retryMs
|
||||||
|
if ms > 30000 then set ms to 30000 end
|
||||||
|
add .hidden to .js-loading in me
|
||||||
|
remove .hidden from .js-neterr in me
|
||||||
|
remove .opacity-0 from me
|
||||||
|
add .opacity-100 to me
|
||||||
|
wait ms ms
|
||||||
|
trigger sentinel:retry
|
||||||
|
set ms to ms * 2
|
||||||
|
if ms > 30000 then set ms to 30000 end
|
||||||
|
set me.dataset.retryMs to ms
|
||||||
|
end
|
||||||
|
|
||||||
|
on htmx:sendError call backoff()
|
||||||
|
on htmx:responseError call backoff()
|
||||||
|
on htmx:timeout call backoff()
|
||||||
|
"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{% include "sentinel/desktop_content.html" %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
48
browser/templates/_types/blog/_main_panel.html
Normal file
48
browser/templates/_types/blog/_main_panel.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
{# View toggle bar - desktop only #}
|
||||||
|
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
||||||
|
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
||||||
|
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ list_href }}"
|
||||||
|
hx-get="{{ list_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||||
|
title="List view"
|
||||||
|
_="on click js localStorage.removeItem('blog_view') end"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{ tile_href }}"
|
||||||
|
hx-get="{{ tile_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
|
||||||
|
title="Tile view"
|
||||||
|
_="on click js localStorage.setItem('blog_view','tile') end"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Cards container - list or grid based on view #}
|
||||||
|
{% if view == 'tile' %}
|
||||||
|
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{% include "_types/blog/_cards.html" %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="max-w-full px-3 py-3 space-y-3">
|
||||||
|
{% include "_types/blog/_cards.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pb-8"></div>
|
||||||
40
browser/templates/_types/blog/_oob_elements.html
Normal file
40
browser/templates/_types/blog/_oob_elements.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||||
|
|
||||||
|
{# Import shared OOB macros #}
|
||||||
|
{% from '_types/root/header/_oob_.html' import root_header with context %}
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('root-header-child', 'blog-header-child', '_types/blog/header/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{# Filter container - blog doesn't have child_summary but still needs this element #}
|
||||||
|
{% block filter %}
|
||||||
|
{% include "_types/blog/mobile/_filter/summary.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{# Aside with filters #}
|
||||||
|
{% block aside %}
|
||||||
|
{% include "_types/blog/desktop/menu.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% include '_types/root/_nav.html' %}
|
||||||
|
{% include '_types/root/_nav_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
|
||||||
|
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||||
|
{{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{# --- Edit group form --- #}
|
||||||
|
<form method="post" action="{{ url_for('blog.tag_groups_admin.save', id=group.id) }}"
|
||||||
|
class="border rounded p-4 bg-white space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-stone-600 mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text" name="name" value="{{ group.name }}" required
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs font-medium text-stone-600 mb-1">Colour</label>
|
||||||
|
<input
|
||||||
|
type="text" name="colour" value="{{ group.colour or '' }}" placeholder="#hex"
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<label class="block text-xs font-medium text-stone-600 mb-1">Order</label>
|
||||||
|
<input
|
||||||
|
type="number" name="sort_order" value="{{ group.sort_order }}"
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-stone-600 mb-1">Feature Image URL</label>
|
||||||
|
<input
|
||||||
|
type="text" name="feature_image" value="{{ group.feature_image or '' }}"
|
||||||
|
placeholder="https://..."
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- Tag checkboxes --- #}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-stone-600 mb-2">Assign Tags</label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2">
|
||||||
|
{% for tag in all_tags %}
|
||||||
|
<label class="flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox" name="tag_ids" value="{{ tag.id }}"
|
||||||
|
{% if tag.id in assigned_tag_ids %}checked{% endif %}
|
||||||
|
class="rounded border-stone-300"
|
||||||
|
>
|
||||||
|
{% if tag.feature_image %}
|
||||||
|
<img src="{{ tag.feature_image }}" alt="" class="h-4 w-4 rounded-full object-cover">
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ tag.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# --- Delete form --- #}
|
||||||
|
<form method="post" action="{{ url_for('blog.tag_groups_admin.delete_group', id=group.id) }}"
|
||||||
|
class="border-t pt-4"
|
||||||
|
onsubmit="return confirm('Delete this tag group? Tags will not be deleted.')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="border rounded px-4 py-2 bg-red-600 text-white text-sm">
|
||||||
|
Delete Group
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('tag-groups-header-child', 'tag-groups-edit-child', '_types/blog/admin/tag_groups/_edit_header.html')}}
|
||||||
|
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/root/settings/header/_header.html' import header_row with context %}
|
||||||
|
{{header_row(oob=True)}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='tag-groups-row', oob=oob) %}
|
||||||
|
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||||
|
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
|
||||||
|
|
||||||
|
{# --- Create new group form --- #}
|
||||||
|
<form method="post" action="{{ url_for('blog.tag_groups_admin.create') }}" class="border rounded p-4 bg-white space-y-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<h3 class="text-sm font-semibold text-stone-700">New Group</h3>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<input
|
||||||
|
type="text" name="name" placeholder="Group name" required
|
||||||
|
class="flex-1 border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text" name="colour" placeholder="#colour"
|
||||||
|
class="w-28 border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number" name="sort_order" placeholder="Order" value="0"
|
||||||
|
class="w-20 border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text" name="feature_image" placeholder="Image URL (optional)"
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# --- Existing groups list --- #}
|
||||||
|
{% if groups %}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for group in groups %}
|
||||||
|
<li class="border rounded p-3 bg-white flex items-center gap-3">
|
||||||
|
{% if group.feature_image %}
|
||||||
|
<img src="{{ group.feature_image }}" alt="{{ group.name }}"
|
||||||
|
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||||
|
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}">
|
||||||
|
{{ group.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-1">
|
||||||
|
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}"
|
||||||
|
class="font-medium text-stone-800 hover:underline">
|
||||||
|
{{ group.name }}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-stone-500 ml-2">{{ group.slug }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-stone-500">order: {{ group.sort_order }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-stone-500 text-sm">No tag groups yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- Unassigned tags --- #}
|
||||||
|
{% if unassigned_tags %}
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<h3 class="text-sm font-semibold text-stone-700 mb-2">Unassigned Tags ({{ unassigned_tags|length }})</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for tag in unassigned_tags %}
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||||
|
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
|
||||||
|
|
||||||
|
{% from '_types/root/settings/header/_header.html' import header_row with context %}
|
||||||
|
{{header_row(oob=True)}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block mobile_menu %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
13
browser/templates/_types/blog/admin/tag_groups/edit.html
Normal file
13
browser/templates/_types/blog/admin/tag_groups/edit.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends '_types/blog/admin/tag_groups/index.html' %}
|
||||||
|
|
||||||
|
{% block tag_groups_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import header with context %}
|
||||||
|
{% call header() %}
|
||||||
|
{% from '_types/blog/admin/tag_groups/_edit_header.html' import header_row with context %}
|
||||||
|
{{ header_row() }}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
20
browser/templates/_types/blog/admin/tag_groups/index.html
Normal file
20
browser/templates/_types/blog/admin/tag_groups/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends '_types/root/settings/index.html' %}
|
||||||
|
|
||||||
|
{% block root_settings_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import header with context %}
|
||||||
|
{% call header() %}
|
||||||
|
{% from '_types/blog/admin/tag_groups/_header.html' import header_row with context %}
|
||||||
|
{{ header_row() }}
|
||||||
|
<div id="tag-groups-header-child">
|
||||||
|
{% block tag_groups_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% endblock %}
|
||||||
19
browser/templates/_types/blog/desktop/menu.html
Normal file
19
browser/templates/_types/blog/desktop/menu.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% import '_types/browse/desktop/_filter/search.html' as s %}
|
||||||
|
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
||||||
|
{% include '_types/blog/_action_buttons.html' %}
|
||||||
|
<div
|
||||||
|
id="category-summary-desktop"
|
||||||
|
hxx-swap-oob="outerHTML"
|
||||||
|
>
|
||||||
|
{% include '_types/blog/desktop/menu/tag_groups.html' %}
|
||||||
|
{% include '_types/blog/desktop/menu/authors.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="filter-summary-desktop"
|
||||||
|
hxx-swap-oob="outerHTML"
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
62
browser/templates/_types/blog/desktop/menu/authors.html
Normal file
62
browser/templates/_types/blog/desktop/menu/authors.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% import '_types/blog/_card/author.html' as doauthor %}
|
||||||
|
|
||||||
|
{# Author filter bar #}
|
||||||
|
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
|
||||||
|
<ul class="divide-y flex flex-col gap-3">
|
||||||
|
<li>
|
||||||
|
{% set is_on = (selected_authors | length == 0) %}
|
||||||
|
{% set href =
|
||||||
|
{
|
||||||
|
'remove_author': selected_authors,
|
||||||
|
}|qs
|
||||||
|
|host %}
|
||||||
|
<a
|
||||||
|
class="px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
Any author
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% for author in authors %}
|
||||||
|
<li>
|
||||||
|
{% set is_on = (selected_authors and (author.slug in selected_authors)) %}
|
||||||
|
{% set qs = {"remove_author": author.slug, "page":None}|qs if is_on
|
||||||
|
else {"add_author": author.slug, "page":None}|qs %}
|
||||||
|
{% set href = qs|host %}
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-2 px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
{{doauthor.author(author)}}
|
||||||
|
{% if False and author.bio %}
|
||||||
|
<span class="inline-block flex-1 bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{% if author.bio|length > 50 %}
|
||||||
|
{{ author.bio[:50] ~ "…" }}
|
||||||
|
{% else %}
|
||||||
|
{{ author.bio }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ author.published_post_count }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
70
browser/templates/_types/blog/desktop/menu/tag_groups.html
Normal file
70
browser/templates/_types/blog/desktop/menu/tag_groups.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{# Tag group filter bar #}
|
||||||
|
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
|
||||||
|
<ul class="divide-y flex flex-col gap-3">
|
||||||
|
<li>
|
||||||
|
{% set is_on = (selected_groups | length == 0 and selected_tags | length == 0) %}
|
||||||
|
{% set href =
|
||||||
|
{
|
||||||
|
'remove_group': selected_groups,
|
||||||
|
'remove_tag': selected_tags,
|
||||||
|
}|qs|host %}
|
||||||
|
<a
|
||||||
|
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
Any Topic
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% for group in tag_groups %}
|
||||||
|
{% if group.post_count > 0 or (selected_groups and group.slug in selected_groups) %}
|
||||||
|
<li>
|
||||||
|
{% set is_on = (selected_groups and (group.slug in selected_groups)) %}
|
||||||
|
{% set qs = {"remove_group": group.slug, "page":None}|qs if is_on
|
||||||
|
else {"add_group": group.slug, "page":None}|qs %}
|
||||||
|
{% set href = qs|host %}
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
{% if group.feature_image %}
|
||||||
|
<img
|
||||||
|
src="{{ group.feature_image }}"
|
||||||
|
alt="{{ group.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||||
|
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
|
||||||
|
>
|
||||||
|
{{ group.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
|
||||||
|
{{ group.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ group.post_count }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
59
browser/templates/_types/blog/desktop/menu/tags.html
Normal file
59
browser/templates/_types/blog/desktop/menu/tags.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{% import '_types/blog/_card/tag.html' as dotag %}
|
||||||
|
|
||||||
|
{# Tag filter bar #}
|
||||||
|
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
|
||||||
|
<ul class="divide-y flex flex-col gap-3">
|
||||||
|
<li>
|
||||||
|
{% set is_on = (selected_tags | length == 0) %}
|
||||||
|
{% set href =
|
||||||
|
{
|
||||||
|
'remove_tag': selected_tags,
|
||||||
|
}|qs|host %}
|
||||||
|
<a
|
||||||
|
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
Any Tag
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% for tag in tags %}
|
||||||
|
<li>
|
||||||
|
{% set is_on = (selected_tags and (tag.slug in selected_tags)) %}
|
||||||
|
{% set qs = {"remove_tag": tag.slug, "page":None}|qs if is_on
|
||||||
|
else {"add_tag": tag.slug, "page":None}|qs %}
|
||||||
|
{% set href = qs|host %}
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
{{dotag.tag(tag)}}
|
||||||
|
|
||||||
|
{% if False and tag.description %}
|
||||||
|
<span class="flex-1 inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ tag.description }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ tag.published_post_count }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
7
browser/templates/_types/blog/header/_header.html
Normal file
7
browser/templates/_types/blog/header/_header.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='blog-row', oob=oob) %}
|
||||||
|
<div></div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
37
browser/templates/_types/blog/index.html
Normal file
37
browser/templates/_types/blog/index.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var p = new URLSearchParams(window.location.search);
|
||||||
|
if (!p.has('view')
|
||||||
|
&& window.matchMedia('(min-width: 768px)').matches
|
||||||
|
&& localStorage.getItem('blog_view') === 'tile') {
|
||||||
|
p.set('view', 'tile');
|
||||||
|
window.location.replace(window.location.pathname + '?' + p.toString());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block root_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
|
||||||
|
{% block root_blog_header %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block aside %}
|
||||||
|
{% include "_types/blog/desktop/menu.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block filter %}
|
||||||
|
{% include "_types/blog/mobile/_filter/summary.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
13
browser/templates/_types/blog/mobile/_filter/_hamburger.html
Normal file
13
browser/templates/_types/blog/mobile/_filter/_hamburger.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="md:hidden mx-2 bg-stone-200 rounded">
|
||||||
|
|
||||||
|
|
||||||
|
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24"
|
||||||
|
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
|
||||||
|
<path d="M6 9l6 6 6-6" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
14
browser/templates/_types/blog/mobile/_filter/summary.html
Normal file
14
browser/templates/_types/blog/mobile/_filter/summary.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% import 'macros/layout.html' as layout %}
|
||||||
|
|
||||||
|
{% call layout.details('/filter', 'md:hidden') %}
|
||||||
|
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
|
||||||
|
{% include '_types/blog/mobile/_filter/summary/tag_groups.html' %}
|
||||||
|
{% include '_types/blog/mobile/_filter/summary/authors.html' %}
|
||||||
|
{% endcall %}
|
||||||
|
{% include '_types/blog/_action_buttons.html' %}
|
||||||
|
<div id="filter-details-mobile" style="display:contents">
|
||||||
|
{% include '_types/blog/desktop/menu/tag_groups.html' %}
|
||||||
|
{% include '_types/blog/desktop/menu/authors.html' %}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% if selected_authors and selected_authors|length %}
|
||||||
|
<ul class="relative inline-flex flex-col gap-2">
|
||||||
|
{% for st in selected_authors %}
|
||||||
|
{% for author in authors %}
|
||||||
|
{% if st == author.slug %}
|
||||||
|
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
|
||||||
|
{% if author.profile_image %}
|
||||||
|
<img
|
||||||
|
src="{{ author.profile_image }}"
|
||||||
|
alt="{{ author.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
{# optional fallback circle with first letter #}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||||
|
{{ author.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ author.name }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{author.published_post_count}}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% if selected_groups and selected_groups|length %}
|
||||||
|
<ul class="relative inline-flex flex-col gap-2">
|
||||||
|
{% for sg in selected_groups %}
|
||||||
|
{% for group in tag_groups %}
|
||||||
|
{% if sg == group.slug %}
|
||||||
|
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
|
||||||
|
{% if group.feature_image %}
|
||||||
|
<img
|
||||||
|
src="{{ group.feature_image }}"
|
||||||
|
alt="{{ group.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||||
|
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
|
||||||
|
>
|
||||||
|
{{ group.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ group.name }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{group.post_count}}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% if selected_tags and selected_tags|length %}
|
||||||
|
<ul class="relative inline-flex flex-col gap-2">
|
||||||
|
{% for st in selected_tags %}
|
||||||
|
{% for tag in tags %}
|
||||||
|
{% if st == tag.slug %}
|
||||||
|
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
|
||||||
|
{% if tag.feature_image %}
|
||||||
|
<img
|
||||||
|
src="{{ tag.feature_image }}"
|
||||||
|
alt="{{ tag.name }}"
|
||||||
|
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
{# optional fallback circle with first letter #}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||||
|
{{ tag.name[:1] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{tag.published_post_count}}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
22
browser/templates/_types/blog/not_found.html
Normal file
22
browser/templates/_types/blog/not_found.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8 text-center">
|
||||||
|
<div class="text-6xl mb-4">📝</div>
|
||||||
|
<h1 class="text-2xl font-bold text-stone-800 mb-2">Post Not Found</h1>
|
||||||
|
<p class="text-stone-600 mb-6">
|
||||||
|
The post "{{ slug }}" could not be found.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="{{ url_for('blog.home')|host }}"
|
||||||
|
hx-get="{{ url_for('blog.home')|host }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{ hx_select }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Blog
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
55
browser/templates/_types/blog_drafts/_main_panel.html
Normal file
55
browser/templates/_types/blog_drafts/_main_panel.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<div class="p-4 space-y-4 max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
|
||||||
|
{% set new_href = url_for('blog.new_post')|host %}
|
||||||
|
<a
|
||||||
|
href="{{ new_href }}"
|
||||||
|
hx-get="{{ new_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{ hx_select_search }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fa fa-plus mr-1"></i> New Post
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if drafts %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for draft in drafts %}
|
||||||
|
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ edit_href }}"
|
||||||
|
hx-boost="false"
|
||||||
|
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-stone-900 truncate">
|
||||||
|
{{ draft.title or "Untitled" }}
|
||||||
|
</h3>
|
||||||
|
{% if draft.excerpt %}
|
||||||
|
<p class="text-stone-600 text-sm mt-1 line-clamp-2">
|
||||||
|
{{ draft.excerpt }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if draft.updated_at %}
|
||||||
|
<p class="text-xs text-stone-400 mt-2">
|
||||||
|
Updated: {{ draft.updated_at.strftime("%-d %b %Y at %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 flex-shrink-0">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-stone-500 text-center py-8">No drafts yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
12
browser/templates/_types/blog_drafts/_oob_elements.html
Normal file
12
browser/templates/_types/blog_drafts/_oob_elements.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/blog/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog_drafts/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
11
browser/templates/_types/blog_drafts/index.html
Normal file
11
browser/templates/_types/blog_drafts/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
|
||||||
|
{% block root_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog_drafts/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
259
browser/templates/_types/blog_new/_main_panel.html
Normal file
259
browser/templates/_types/blog_new/_main_panel.html
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
{# ── Error banner ── #}
|
||||||
|
{% if save_error %}
|
||||||
|
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
|
||||||
|
<strong>Save failed:</strong> {{ save_error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" id="lexical-json-input" name="lexical" value="">
|
||||||
|
<input type="hidden" id="feature-image-input" name="feature_image" value="">
|
||||||
|
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">
|
||||||
|
|
||||||
|
{# ── Feature image ── #}
|
||||||
|
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
|
||||||
|
{# Empty state: add link #}
|
||||||
|
<div id="feature-image-empty">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="feature-image-add-btn"
|
||||||
|
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||||
|
>+ Add feature image</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Filled state: image preview + controls #}
|
||||||
|
<div id="feature-image-filled" class="relative hidden">
|
||||||
|
<img
|
||||||
|
id="feature-image-preview"
|
||||||
|
src=""
|
||||||
|
alt=""
|
||||||
|
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
|
||||||
|
>
|
||||||
|
{# Delete button (top-right, visible on hover) #}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="feature-image-delete-btn"
|
||||||
|
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
|
||||||
|
flex items-center justify-center opacity-0 group-hover:opacity-100
|
||||||
|
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||||
|
title="Remove feature image"
|
||||||
|
><i class="fa-solid fa-trash-can"></i></button>
|
||||||
|
|
||||||
|
{# Caption input #}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="feature-image-caption"
|
||||||
|
value=""
|
||||||
|
placeholder="Add a caption..."
|
||||||
|
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
|
||||||
|
outline-none placeholder:text-stone-300 focus:text-stone-700"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Upload spinner overlay #}
|
||||||
|
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Hidden file input #}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="feature-image-file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Title ── #}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value=""
|
||||||
|
placeholder="Post title..."
|
||||||
|
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
||||||
|
placeholder:text-stone-300 mb-[8px] leading-tight"
|
||||||
|
>
|
||||||
|
|
||||||
|
{# ── Excerpt ── #}
|
||||||
|
<textarea
|
||||||
|
name="custom_excerpt"
|
||||||
|
rows="1"
|
||||||
|
placeholder="Add an excerpt..."
|
||||||
|
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
|
||||||
|
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
{# ── Editor mount point ── #}
|
||||||
|
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
|
||||||
|
|
||||||
|
{# ── Status + Save footer ── #}
|
||||||
|
<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||||
|
>
|
||||||
|
<option value="draft" selected>Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
||||||
|
hover:bg-stone-800 transition-colors cursor-pointer"
|
||||||
|
>Create Post</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Koenig editor assets ── #}
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
|
||||||
|
<style>
|
||||||
|
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
|
||||||
|
We apply that via JS (see init() below) so the header bars render at
|
||||||
|
normal size on first paint. A beforeSwap listener restores the
|
||||||
|
default when navigating away. */
|
||||||
|
#lexical-editor { display: flow-root; }
|
||||||
|
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
|
||||||
|
#lexical-editor [data-kg-card="html"] * { float: none !important; }
|
||||||
|
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
|
||||||
|
</style>
|
||||||
|
<script src="{{ asset_url('scripts/editor.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
|
||||||
|
restore default when navigating away via HTMX ── */
|
||||||
|
function applyEditorFontSize() {
|
||||||
|
document.documentElement.style.fontSize = '62.5%';
|
||||||
|
document.body.style.fontSize = '1.6rem';
|
||||||
|
}
|
||||||
|
function restoreDefaultFontSize() {
|
||||||
|
document.documentElement.style.fontSize = '';
|
||||||
|
document.body.style.fontSize = '';
|
||||||
|
}
|
||||||
|
applyEditorFontSize();
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
|
||||||
|
if (e.detail.target && e.detail.target.id === 'main-panel') {
|
||||||
|
restoreDefaultFontSize();
|
||||||
|
document.body.removeEventListener('htmx:beforeSwap', cleanup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
|
||||||
|
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
|
||||||
|
var uploadUrls = {
|
||||||
|
image: uploadUrl,
|
||||||
|
media: '{{ url_for("blog.editor_api.upload_media") }}',
|
||||||
|
file: '{{ url_for("blog.editor_api.upload_file") }}',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Feature image upload / delete / replace ── */
|
||||||
|
var fileInput = document.getElementById('feature-image-file');
|
||||||
|
var addBtn = document.getElementById('feature-image-add-btn');
|
||||||
|
var deleteBtn = document.getElementById('feature-image-delete-btn');
|
||||||
|
var preview = document.getElementById('feature-image-preview');
|
||||||
|
var emptyState = document.getElementById('feature-image-empty');
|
||||||
|
var filledState = document.getElementById('feature-image-filled');
|
||||||
|
var hiddenUrl = document.getElementById('feature-image-input');
|
||||||
|
var hiddenCaption = document.getElementById('feature-image-caption-input');
|
||||||
|
var captionInput = document.getElementById('feature-image-caption');
|
||||||
|
var uploading = document.getElementById('feature-image-uploading');
|
||||||
|
|
||||||
|
function showFilled(url) {
|
||||||
|
preview.src = url;
|
||||||
|
hiddenUrl.value = url;
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
filledState.classList.remove('hidden');
|
||||||
|
uploading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmpty() {
|
||||||
|
preview.src = '';
|
||||||
|
hiddenUrl.value = '';
|
||||||
|
hiddenCaption.value = '';
|
||||||
|
captionInput.value = '';
|
||||||
|
emptyState.classList.remove('hidden');
|
||||||
|
filledState.classList.add('hidden');
|
||||||
|
uploading.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
uploading.classList.remove('hidden');
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
headers: { 'X-CSRFToken': csrfToken },
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
var url = data.images && data.images[0] && data.images[0].url;
|
||||||
|
if (url) showFilled(url);
|
||||||
|
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
showEmpty();
|
||||||
|
alert(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', function() { fileInput.click(); });
|
||||||
|
preview.addEventListener('click', function() { fileInput.click(); });
|
||||||
|
deleteBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showEmpty();
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
if (fileInput.files && fileInput.files[0]) {
|
||||||
|
uploadFile(fileInput.files[0]);
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
captionInput.addEventListener('input', function() {
|
||||||
|
hiddenCaption.value = captionInput.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Auto-resize excerpt textarea ── */
|
||||||
|
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
|
||||||
|
function autoResize() {
|
||||||
|
excerpt.style.height = 'auto';
|
||||||
|
excerpt.style.height = excerpt.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
excerpt.addEventListener('input', autoResize);
|
||||||
|
autoResize();
|
||||||
|
|
||||||
|
/* ── Mount Koenig editor ── */
|
||||||
|
window.mountEditor('lexical-editor', {
|
||||||
|
initialJson: null,
|
||||||
|
csrfToken: csrfToken,
|
||||||
|
uploadUrls: uploadUrls,
|
||||||
|
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
|
||||||
|
unsplashApiKey: '{{ unsplash_api_key or "" }}',
|
||||||
|
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Ctrl-S / Cmd-S to save ── */
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('post-new-form').requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* editor.js loads synchronously on full page loads but asynchronously
|
||||||
|
when HTMX swaps the content in, so wait for it if needed. */
|
||||||
|
if (typeof window.mountEditor === 'function') {
|
||||||
|
init();
|
||||||
|
} else {
|
||||||
|
var _t = setInterval(function() {
|
||||||
|
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
12
browser/templates/_types/blog_new/_oob_elements.html
Normal file
12
browser/templates/_types/blog_new/_oob_elements.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'oob_elements.html' %}
|
||||||
|
|
||||||
|
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||||
|
|
||||||
|
{% block oobs %}
|
||||||
|
{% from '_types/blog/header/_header.html' import header_row with context %}
|
||||||
|
{{ header_row(oob=True) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog_new/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
11
browser/templates/_types/blog_new/index.html
Normal file
11
browser/templates/_types/blog_new/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
|
||||||
|
{% block root_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/blog_new/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user