From 6dac8570ad6ae61ad50bbd0ced9d4b08c902537a Mon Sep 17 00:00:00 2001 From: Deeman Date: Thu, 19 Feb 2026 20:37:44 +0100 Subject: [PATCH] Fix web/ startup errors and sync with boilerplate - Load .env via python-dotenv in core.py - Skip analytics DB open if file doesn't exist - Guard dashboard analytics calls when DB not available - Namespace admin templates under admin/ to avoid blueprint conflicts - Add dev-login routes for user and admin (DEBUG only) - Update .copier-answers.yml src_path to GitLab remote Co-Authored-By: Claude Sonnet 4.6 --- web/.copier-answers.yml | 2 +- web/.env.example | 2 +- web/.gitignore | 4 + web/Makefile | 12 + web/scripts/dev_run.sh | 131 +++++++++ web/scripts/dev_setup.sh | 108 +++++++ web/src/beanflows/admin/routes.py | 20 +- .../admin/templates/{ => admin}/index.html | 0 .../admin/templates/{ => admin}/login.html | 0 .../admin/templates/{ => admin}/tasks.html | 0 .../templates/{ => admin}/user_detail.html | 0 .../admin/templates/{ => admin}/users.html | 0 web/src/beanflows/analytics.py | 6 +- web/src/beanflows/app.py | 2 +- web/src/beanflows/core.py | 6 +- web/src/beanflows/dashboard/routes.py | 25 +- web/src/beanflows/static/css/input.css | 275 ++++++++++++++++++ web/src/beanflows/templates/base.html | 97 +++--- 18 files changed, 615 insertions(+), 75 deletions(-) create mode 100644 web/Makefile create mode 100644 web/scripts/dev_run.sh create mode 100644 web/scripts/dev_setup.sh rename web/src/beanflows/admin/templates/{ => admin}/index.html (100%) rename web/src/beanflows/admin/templates/{ => admin}/login.html (100%) rename web/src/beanflows/admin/templates/{ => admin}/tasks.html (100%) rename web/src/beanflows/admin/templates/{ => admin}/user_detail.html (100%) rename web/src/beanflows/admin/templates/{ => admin}/users.html (100%) create mode 100644 web/src/beanflows/static/css/input.css diff --git a/web/.copier-answers.yml b/web/.copier-answers.yml index bee3b70..96f3567 100644 --- a/web/.copier-answers.yml +++ b/web/.copier-answers.yml @@ -1,6 +1,6 @@ # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY _commit: v0.3.0 -_src_path: /home/Deeman/Projects/quart_saas_boilerplate +_src_path: git@gitlab.com:deemanone/materia_saas_boilerplate.master.git author_email: hendrik@beanflows.coffee author_name: Hendrik Deeman base_url: https://beanflows.coffee diff --git a/web/.env.example b/web/.env.example index 30940aa..5130434 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,7 +1,7 @@ # App APP_NAME=BeanFlows SECRET_KEY=change-me-generate-a-real-secret -BASE_URL=http://localhost:5000 +BASE_URL=http://localhost:5001 DEBUG=true ADMIN_PASSWORD=admin diff --git a/web/.gitignore b/web/.gitignore index cea4fff..8b6d017 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -35,3 +35,7 @@ htmlcov/ dist/ build/ *.egg-info/ + +# Tailwind CSS +bin/ +src/beanflows/static/css/output.css diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 0000000..d113388 --- /dev/null +++ b/web/Makefile @@ -0,0 +1,12 @@ +TAILWIND := ./bin/tailwindcss + +bin/tailwindcss: + @mkdir -p bin + curl -sLo bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 + chmod +x bin/tailwindcss + +css-build: bin/tailwindcss + $(TAILWIND) -i src/beanflows/static/css/input.css -o src/beanflows/static/css/output.css --minify + +css-watch: bin/tailwindcss + $(TAILWIND) -i src/beanflows/static/css/input.css -o src/beanflows/static/css/output.css --watch diff --git a/web/scripts/dev_run.sh b/web/scripts/dev_run.sh new file mode 100644 index 0000000..3bdea31 --- /dev/null +++ b/web/scripts/dev_run.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Start all BeanFlows dev processes with colored, labeled output. +# +# Usage: ./scripts/dev_run.sh +# +# On each start: runs migrations (idempotent), builds CSS, optionally starts +# ngrok for Paddle webhook forwarding, then starts app (port 5000), +# background worker, and CSS watcher. +# Ctrl-C stops everything cleanly. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# -- Colors & helpers -------------------------------------------------------- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +COLOR_APP='\033[0;36m' # cyan +COLOR_WORKER='\033[0;33m' # yellow +COLOR_CSS='\033[0;35m' # magenta + +info() { echo -e "${BLUE}==>${NC} ${BOLD}$1${NC}"; } +ok() { echo -e "${GREEN} ✓${NC} $1"; } +warn() { echo -e "${YELLOW} !${NC} $1"; } +fail() { echo -e "${RED} ✗${NC} $1"; exit 1; } + +# -- Preflight --------------------------------------------------------------- + +if [ ! -f .env ]; then + fail ".env not found. Run ./scripts/dev_setup.sh first." +fi + +# Load config from .env +PADDLE_API_KEY=$(grep '^PADDLE_API_KEY=' .env 2>/dev/null | cut -d= -f2- || true) +PADDLE_NOTIFICATION_SETTING_ID=$(grep '^PADDLE_NOTIFICATION_SETTING_ID=' .env 2>/dev/null | cut -d= -f2- || true) + +# -- Preparation ------------------------------------------------------------- + +info "Running migrations" +uv run --package beanflows python -m beanflows.migrations.migrate +ok "Migrations applied" + +info "Building CSS" +make css-build +ok "CSS built" + +# -- Process management ------------------------------------------------------ + +PIDS=() + +cleanup() { + echo "" + echo -e "${BOLD}Stopping all processes...${NC}" + for pid in "${PIDS[@]}"; do + pkill -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true + echo "Done." + exit 0 +} + +trap cleanup SIGINT SIGTERM + +# Prefix each line of a command's output with a colored label. +run_with_label() { + local color="$1" label="$2" + shift 2 + "$@" > >(while IFS= read -r line; do echo -e "${color}[${label}]${NC} ${line}"; done) 2>&1 & + PIDS+=($!) +} + +# -- Ngrok tunnel (if Paddle is configured) ---------------------------------- + +TUNNEL_URL="" + +if [ -n "$PADDLE_API_KEY" ] && [ -n "$PADDLE_NOTIFICATION_SETTING_ID" ]; then + if command -v ngrok >/dev/null 2>&1; then + info "Starting ngrok tunnel for Paddle webhooks" + ngrok http 5001 --log=stdout --log-level=warn > /tmp/beanflows-ngrok.log 2>&1 & + NGROK_PID=$! + PIDS+=($NGROK_PID) + + # Wait for ngrok to be ready (up to 10 seconds) + WAIT_SECONDS=10 + for i in $(seq 1 $WAIT_SECONDS); do + TUNNEL_URL=$(curl -s http://localhost:4040/api/tunnels 2>/dev/null \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['tunnels'][0]['public_url'])" 2>/dev/null) \ + && break + sleep 1 + done + + if [ -n "$TUNNEL_URL" ]; then + ok "ngrok tunnel: $TUNNEL_URL" + else + warn "ngrok started but tunnel URL not available — webhooks won't reach localhost" + fi + else + warn "ngrok not installed — Paddle webhooks won't reach localhost" + warn "Install: https://ngrok.com/download" + fi +elif [ -n "$PADDLE_API_KEY" ]; then + warn "PADDLE_NOTIFICATION_SETTING_ID not set — webhook forwarding disabled" +fi + +# -- Start processes --------------------------------------------------------- + +echo "" +echo -e "${BOLD}Starting BeanFlows dev environment${NC}" +echo "" +echo " app: http://localhost:5001" +echo " dev-login: http://localhost:5001/auth/dev-login" +echo " admin: http://localhost:5001/admin/dev-login" +if [ -n "$TUNNEL_URL" ]; then + echo " tunnel: $TUNNEL_URL" +fi +echo "" +echo "Press Ctrl-C to stop all processes." +echo "" + +run_with_label "$COLOR_APP" "app " uv run --package beanflows python -m beanflows.app +run_with_label "$COLOR_WORKER" "worker" uv run --package beanflows python -m beanflows.worker +run_with_label "$COLOR_CSS" "css " make css-watch + +wait diff --git a/web/scripts/dev_setup.sh b/web/scripts/dev_setup.sh new file mode 100644 index 0000000..e11704a --- /dev/null +++ b/web/scripts/dev_setup.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Bootstrap a local dev environment for BeanFlows. +# +# Usage: ./scripts/dev_setup.sh +# +# Checks prerequisites, installs deps, creates .env, runs migrations, +# and builds CSS. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# -- Colors & helpers ------------------------------------------------------- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${BLUE}==>${NC} ${BOLD}$1${NC}"; } +ok() { echo -e "${GREEN} ✓${NC} $1"; } +warn() { echo -e "${YELLOW} !${NC} $1"; } +fail() { echo -e "${RED} ✗${NC} $1"; exit 1; } + +# -- Prerequisites ----------------------------------------------------------- + +info "Checking prerequisites" + +command -v python3 >/dev/null 2>&1 || fail "python3 not found. Install Python 3.13+." +command -v uv >/dev/null 2>&1 || fail "uv not found. Install: https://docs.astral.sh/uv/getting-started/installation/" + +PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +ok "python3 ${PYTHON_VERSION}" +ok "uv $(uv --version 2>/dev/null | head -1)" + +# -- Install dependencies ---------------------------------------------------- + +info "Installing Python dependencies" +# Sync from workspace root to get all packages +(cd .. && uv sync --all-packages --all-groups) +ok "Dependencies installed" + +# -- .env -------------------------------------------------------------------- + +if [ -f .env ]; then + warn ".env already exists — skipping creation" +else + info "Creating .env from .env.example" + cp .env.example .env + + # Auto-generate SECRET_KEY + SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") + sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET_KEY}/" .env + ok "SECRET_KEY generated" + + # Prompt for optional keys + echo "" + echo -e "${BOLD}Optional API keys${NC} (press Enter to skip — everything works without them)" + echo "" + + read -rp " Paddle API key (for checkout testing): " PADDLE_API_KEY + if [ -n "$PADDLE_API_KEY" ]; then + sed -i "s/^PADDLE_API_KEY=.*/PADDLE_API_KEY=${PADDLE_API_KEY}/" .env + ok "PADDLE_API_KEY set" + fi + + read -rp " Resend API key (for email testing): " RESEND_API_KEY + if [ -n "$RESEND_API_KEY" ]; then + sed -i "s/^RESEND_API_KEY=.*/RESEND_API_KEY=${RESEND_API_KEY}/" .env + ok "RESEND_API_KEY set" + fi + + echo "" + ok ".env created" +fi + +# -- Migrations --------------------------------------------------------------- + +info "Running database migrations" +uv run --package beanflows python -m beanflows.migrations.migrate +ok "Migrations applied" + +# -- CSS build ---------------------------------------------------------------- + +info "Building CSS" +make css-build +ok "CSS built" + +# -- Summary ------------------------------------------------------------------ + +echo "" +echo -e "${GREEN}${BOLD}Setup complete!${NC}" +echo "" +echo " Start all services: ./scripts/dev_run.sh" +echo "" +echo " URLs:" +echo " App: http://localhost:5001" +echo " Admin: http://localhost:5001/admin (password: admin)" +echo "" +echo " Email:" +if grep -q '^RESEND_API_KEY=.\+' .env 2>/dev/null; then + echo " Resend configured — emails sent via API" +else + echo " No Resend key — emails print to console" +fi +echo "" diff --git a/web/src/beanflows/admin/routes.py b/web/src/beanflows/admin/routes.py index 1e9f0db..d66ed1f 100644 --- a/web/src/beanflows/admin/routes.py +++ b/web/src/beanflows/admin/routes.py @@ -189,6 +189,16 @@ def admin_required(f): # Routes # ============================================================================= +@bp.route("/dev-login") +async def dev_login(): + """Instant admin login for development. Only works in DEBUG mode.""" + if not config.DEBUG: + return "Not available", 404 + session["is_admin"] = True + await flash("Dev admin login.", "success") + return redirect(url_for("admin.index")) + + @bp.route("/login", methods=["GET", "POST"]) @csrf_protect async def login(): @@ -213,7 +223,7 @@ async def login(): else: await flash("Invalid password.", "error") - return await render_template("login.html") + return await render_template("admin/login.html") @bp.route("/logout", methods=["POST"]) @@ -234,7 +244,7 @@ async def index(): failed_tasks = await get_failed_tasks() return await render_template( - "index.html", + "admin/index.html", stats=stats, recent_users=recent_users, failed_tasks=failed_tasks, @@ -253,7 +263,7 @@ async def users(): user_list = await get_users(limit=per_page, offset=offset, search=search or None) return await render_template( - "users.html", + "admin/users.html", users=user_list, search=search, page=page, @@ -269,7 +279,7 @@ async def user_detail(user_id: int): await flash("User not found.", "error") return redirect(url_for("admin.users")) - return await render_template("user_detail.html", user=user) + return await render_template("admin/user_detail.html", user=user) @bp.route("/users//impersonate", methods=["POST"]) @@ -308,7 +318,7 @@ async def tasks(): failed = await get_failed_tasks() return await render_template( - "tasks.html", + "admin/tasks.html", tasks=task_list, failed_tasks=failed, ) diff --git a/web/src/beanflows/admin/templates/index.html b/web/src/beanflows/admin/templates/admin/index.html similarity index 100% rename from web/src/beanflows/admin/templates/index.html rename to web/src/beanflows/admin/templates/admin/index.html diff --git a/web/src/beanflows/admin/templates/login.html b/web/src/beanflows/admin/templates/admin/login.html similarity index 100% rename from web/src/beanflows/admin/templates/login.html rename to web/src/beanflows/admin/templates/admin/login.html diff --git a/web/src/beanflows/admin/templates/tasks.html b/web/src/beanflows/admin/templates/admin/tasks.html similarity index 100% rename from web/src/beanflows/admin/templates/tasks.html rename to web/src/beanflows/admin/templates/admin/tasks.html diff --git a/web/src/beanflows/admin/templates/user_detail.html b/web/src/beanflows/admin/templates/admin/user_detail.html similarity index 100% rename from web/src/beanflows/admin/templates/user_detail.html rename to web/src/beanflows/admin/templates/admin/user_detail.html diff --git a/web/src/beanflows/admin/templates/users.html b/web/src/beanflows/admin/templates/admin/users.html similarity index 100% rename from web/src/beanflows/admin/templates/users.html rename to web/src/beanflows/admin/templates/admin/users.html diff --git a/web/src/beanflows/analytics.py b/web/src/beanflows/analytics.py index 5cea45f..f146bed 100644 --- a/web/src/beanflows/analytics.py +++ b/web/src/beanflows/analytics.py @@ -33,10 +33,14 @@ _conn: duckdb.DuckDBPyConnection | None = None def open_analytics_db() -> None: - """Open read-only DuckDB connection.""" + """Open read-only DuckDB connection. No-op if the database file does not exist.""" global _conn + import pathlib db_path = os.getenv("DUCKDB_PATH", "") assert db_path, "DUCKDB_PATH environment variable must be set" + if not pathlib.Path(db_path).exists(): + print(f"[analytics] DuckDB not found at {db_path!r} — analytics disabled") + return _conn = duckdb.connect(db_path, read_only=True) diff --git a/web/src/beanflows/app.py b/web/src/beanflows/app.py index c9771e5..1fc0648 100644 --- a/web/src/beanflows/app.py +++ b/web/src/beanflows/app.py @@ -120,4 +120,4 @@ app = create_app() if __name__ == "__main__": - app.run(debug=config.DEBUG, port=5000) + app.run(debug=config.DEBUG, port=5001) diff --git a/web/src/beanflows/core.py b/web/src/beanflows/core.py index 5caa7e8..2479d73 100644 --- a/web/src/beanflows/core.py +++ b/web/src/beanflows/core.py @@ -12,6 +12,10 @@ from functools import wraps from datetime import datetime, timedelta from contextvars import ContextVar from quart import request, session, g +from dotenv import load_dotenv + +# web/.env is three levels up from web/src/beanflows/core.py +load_dotenv(Path(__file__).parent.parent.parent / ".env", override=False) # ============================================================================= # Configuration @@ -20,7 +24,7 @@ from quart import request, session, g class Config: APP_NAME: str = os.getenv("APP_NAME", "BeanFlows") SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production") - BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5000") + BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5001") DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db") diff --git a/web/src/beanflows/dashboard/routes.py b/web/src/beanflows/dashboard/routes.py index f3cc834..11f3b7f 100644 --- a/web/src/beanflows/dashboard/routes.py +++ b/web/src/beanflows/dashboard/routes.py @@ -99,17 +99,20 @@ async def index(): stats = await get_user_stats(g.user["id"]) plan = user.get("plan") or "free" - # Fetch all analytics data in parallel - time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather( - analytics.get_global_time_series( - analytics.COFFEE_COMMODITY_CODE, - ["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"], - ), - analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10), - analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE), - analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE), - analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15), - ) + # Fetch all analytics data in parallel (empty lists if DB not available) + if analytics._conn is not None: + time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather( + analytics.get_global_time_series( + analytics.COFFEE_COMMODITY_CODE, + ["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"], + ), + analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10), + analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE), + analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE), + analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15), + ) + else: + time_series, top_producers, stu_trend, balance, yoy = [], [], [], [], [] # Latest global snapshot for key metric cards latest = time_series[-1] if time_series else {} diff --git a/web/src/beanflows/static/css/input.css b/web/src/beanflows/static/css/input.css new file mode 100644 index 0000000..147282a --- /dev/null +++ b/web/src/beanflows/static/css/input.css @@ -0,0 +1,275 @@ +@import "tailwindcss"; + +/* ── BeanFlows Brand Theme ── */ +@theme { + --font-display: "DM Sans", ui-sans-serif, system-ui, sans-serif; + --font-sans: "DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, "Cascadia Code", monospace; + + --color-espresso: #2C1810; + --color-roast: #4A2C1A; + --color-copper: #B45309; + --color-copper-hover: #92400E; + --color-bean-green: #15803D; + --color-forest: #064E3B; + --color-cream: #FFFBF5; + --color-latte: #F5F0EB; + --color-parchment: #E8DFD5; + --color-stone: #78716C; + --color-stone-dark: #57534E; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-warning: #D97706; +} + +/* ── Base layer ── */ +@layer base { + body { + @apply bg-cream text-stone-dark font-sans antialiased; + } + h1, h2, h3 { + font-family: var(--font-display); + @apply text-espresso font-bold tracking-tight; + } + h4, h5, h6 { + @apply text-roast font-semibold; + } + a { + @apply text-copper hover:text-copper-hover transition-colors; + } + hr { + @apply border-parchment my-6; + } +} + +/* ── Component classes ── */ +@layer components { + /* ── Navigation ── */ + .nav-bar { + position: sticky; + top: 0; + z-index: 50; + background: rgba(255, 251, 245, 0.85); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(232, 223, 213, 0.7); + } + .nav-inner { + max-width: 72rem; + margin: 0 auto; + padding: 0 1rem; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + } + .nav-logo { + flex-shrink: 0; + font-family: var(--font-display); + font-weight: 700; + font-size: 1.125rem; + color: var(--color-espresso); + text-decoration: none; + } + .nav-logo:hover { + color: var(--color-espresso); + } + .nav-links { + display: flex; + align-items: center; + gap: 1.25rem; + font-size: 0.875rem; + font-weight: 500; + } + .nav-links a { + color: var(--color-stone-dark); + text-decoration: none; + transition: color 0.15s; + } + .nav-links a:hover { + color: var(--color-copper); + } + a.nav-auth-btn, + button.nav-auth-btn { + display: inline-flex; + align-items: center; + padding: 6px 16px; + border: none; + border-radius: 10px; + font-size: 0.8125rem; + font-weight: 600; + color: #fff; + background: var(--color-copper); + cursor: pointer; + text-decoration: none; + box-shadow: 0 2px 8px rgba(180, 83, 9, 0.25); + transition: background 0.15s; + } + a.nav-auth-btn:hover, + button.nav-auth-btn:hover { + background: var(--color-copper-hover); + color: #fff; + } + .nav-badge { + @apply bg-copper/10 text-copper px-2 py-0.5 text-xs font-semibold rounded-full; + } + .nav-form { + margin: 0; + padding: 0; + display: inline; + } + @media (max-width: 768px) { + .nav-links { display: none; } + .nav-inner { justify-content: center; } + } + + /* Page container */ + .container-page { + @apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8; + } + + /* Cards */ + .card { + @apply bg-white border border-parchment rounded-2xl p-6 mb-6 shadow-sm; + } + .card-header { + @apply border-b border-parchment pb-3 mb-4 text-sm text-stone font-medium; + } + + /* Buttons — shared base */ + .btn, .btn-secondary, .btn-danger { + @apply inline-flex items-center justify-center px-5 py-2.5 rounded-xl + font-semibold text-sm transition-colors cursor-pointer + focus:outline-none focus:ring-2 focus:ring-copper/50; + } + .btn { + @apply bg-copper text-white hover:bg-copper-hover shadow-[0_2px_10px_rgba(180,83,9,0.25)]; + } + .btn-secondary { + @apply bg-stone-dark text-white hover:bg-espresso; + } + .btn-danger { + @apply bg-danger text-white hover:bg-danger-hover; + } + .btn-outline { + @apply inline-flex items-center justify-center px-5 py-2.5 rounded-xl + font-semibold text-sm transition-colors cursor-pointer + bg-transparent text-stone-dark border border-parchment + hover:bg-latte hover:text-espresso + focus:outline-none focus:ring-2 focus:ring-copper/50; + } + .btn-sm { + @apply px-3 py-1.5 text-xs; + } + + /* Forms */ + .form-label { + @apply block text-sm font-medium text-roast mb-1; + } + .form-input { + @apply w-full px-3 py-2 rounded-xl border border-parchment bg-white + text-stone-dark placeholder-stone + focus:outline-none focus:ring-2 focus:ring-copper/50 focus:border-copper + transition-colors; + } + .form-hint { + @apply text-xs text-stone mt-1; + } + + /* Tables */ + .table { + @apply w-full text-sm; + } + .table th { + @apply text-left px-3 py-2 text-xs font-semibold text-stone uppercase tracking-wider + border-b-2 border-parchment; + } + .table td { + @apply px-3 py-2 border-b border-parchment text-stone-dark; + } + .table tbody tr:hover { + @apply bg-latte; + } + + /* Flash messages */ + .flash, .flash-error, .flash-success, .flash-warning { + @apply px-4 py-3 rounded-xl mb-4 border-l-4 bg-white text-stone-dark text-sm; + } + .flash { + @apply border-copper; + } + .flash-error { + @apply border-danger; + } + .flash-success { + @apply border-bean-green; + } + .flash-warning { + @apply border-warning; + } + + /* Badges */ + .badge, .badge-success, .badge-danger, .badge-warning { + @apply inline-block px-2 py-0.5 text-xs font-semibold rounded-full; + } + .badge { + @apply bg-copper/10 text-copper; + } + .badge-success { + @apply bg-bean-green/10 text-bean-green; + } + .badge-danger { + @apply bg-danger/10 text-danger; + } + .badge-warning { + @apply bg-warning/10 text-warning; + } + + /* Heading group */ + .heading-group { + @apply mb-6; + } + .heading-group h1, + .heading-group h2 { + @apply mb-1; + } + .heading-group p { + @apply text-stone text-lg; + } + + /* Grid helpers */ + .grid-auto { + @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6; + } + .grid-2 { + @apply grid grid-cols-1 md:grid-cols-2 gap-6; + } + .grid-3 { + @apply grid grid-cols-1 md:grid-cols-3 gap-6; + } + .grid-4 { + @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6; + } + + /* Monospace data display */ + .metric { + @apply font-mono text-espresso; + } + .mono { + @apply font-mono; + } + + /* HTMX loading indicators */ + .htmx-indicator { + display: none; + } + .htmx-request .htmx-indicator, + .htmx-request.htmx-indicator { + display: inline; + } + + /* Code blocks */ + code { + @apply font-mono text-sm bg-latte px-1 py-0.5 rounded; + } +} diff --git a/web/src/beanflows/templates/base.html b/web/src/beanflows/templates/base.html index 663bd9a..df30354 100644 --- a/web/src/beanflows/templates/base.html +++ b/web/src/beanflows/templates/base.html @@ -1,97 +1,86 @@ - + {% block title %}{{ config.APP_NAME }}{% endblock %} - - - - - - - + + + + {% block head %}{% endblock %} - - + {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} -
+
{% for category, message in messages %} -
+
{{ message }} -
+
{% endfor %}
{% endif %} {% endwith %} - + {% block content %}{% endblock %} - + -