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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
web/.gitignore
vendored
4
web/.gitignore
vendored
@@ -35,3 +35,7 @@ htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Tailwind CSS
|
||||
bin/
|
||||
src/beanflows/static/css/output.css
|
||||
|
||||
12
web/Makefile
Normal file
12
web/Makefile
Normal file
@@ -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
|
||||
131
web/scripts/dev_run.sh
Normal file
131
web/scripts/dev_run.sh
Normal file
@@ -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
|
||||
108
web/scripts/dev_setup.sh
Normal file
108
web/scripts/dev_setup.sh
Normal file
@@ -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 ""
|
||||
@@ -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/<int:user_id>/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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -120,4 +120,4 @@ app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=config.DEBUG, port=5000)
|
||||
app.run(debug=config.DEBUG, port=5001)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -99,7 +99,8 @@ async def index():
|
||||
stats = await get_user_stats(g.user["id"])
|
||||
plan = user.get("plan") or "free"
|
||||
|
||||
# Fetch all analytics data in parallel
|
||||
# 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,
|
||||
@@ -110,6 +111,8 @@ async def index():
|
||||
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 {}
|
||||
|
||||
275
web/src/beanflows/static/css/input.css
Normal file
275
web/src/beanflows/static/css/input.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ config.APP_NAME }}{% endblock %}</title>
|
||||
|
||||
<!-- Pico CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
|
||||
<!-- Custom styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
|
||||
<!-- Tailwind CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li><a href="{{ url_for('public.landing') }}"><strong>{{ config.APP_NAME }}</strong></a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('public.features') }}">Features</a></li>
|
||||
<li><a href="{{ url_for('billing.pricing') }}">Pricing</a></li>
|
||||
<nav class="nav-bar">
|
||||
<div class="nav-inner">
|
||||
<a href="{{ url_for('public.landing') }}" class="nav-logo">{{ config.APP_NAME }}</a>
|
||||
<div class="nav-links">
|
||||
<a href="{{ url_for('public.features') }}">Features</a>
|
||||
<a href="{{ url_for('billing.pricing') }}">Pricing</a>
|
||||
{% if user %}
|
||||
<li><a href="{{ url_for('dashboard.index') }}">Dashboard</a></li>
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<li><a href="{{ url_for('admin.index') }}"><mark>Admin</mark></a></li>
|
||||
<a href="{{ url_for('admin.index') }}"><span class="nav-badge">Admin</span></a>
|
||||
{% endif %}
|
||||
<li>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" style="margin: 0;">
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="outline secondary" style="padding: 0.5rem 1rem; margin: 0;">Sign Out</button>
|
||||
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
|
||||
<li><a href="{{ url_for('auth.signup') }}" role="button">Get Started</a></li>
|
||||
<a href="{{ url_for('auth.login') }}">Sign In</a>
|
||||
<a href="{{ url_for('auth.signup') }}" class="nav-auth-btn">Get Started</a>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Flash messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="container">
|
||||
<div class="container-page mt-4">
|
||||
{% for category, message in messages %}
|
||||
<article
|
||||
style="padding: 1rem; margin-bottom: 1rem;
|
||||
{% if category == 'error' %}border-left: 4px solid var(--del-color);
|
||||
{% elif category == 'success' %}border-left: 4px solid var(--ins-color);
|
||||
{% elif category == 'warning' %}border-left: 4px solid var(--mark-background-color);
|
||||
{% else %}border-left: 4px solid var(--primary);{% endif %}"
|
||||
>
|
||||
<div class="{% if category == 'error' %}flash-error{% elif category == 'success' %}flash-success{% elif category == 'warning' %}flash-warning{% else %}flash{% endif %}">
|
||||
{{ message }}
|
||||
</article>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -62,34 +51,34 @@
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="container" style="margin-top: 4rem; padding: 2rem 0; border-top: 1px solid var(--muted-border-color);">
|
||||
<div class="grid">
|
||||
<footer class="container-page mt-16 py-8 border-t border-parchment">
|
||||
<div class="grid-3">
|
||||
<div>
|
||||
<strong>{{ config.APP_NAME }}</strong>
|
||||
<p><small>Coffee market intelligence for independent traders.</small></p>
|
||||
<strong class="text-espresso">{{ config.APP_NAME }}</strong>
|
||||
<p class="text-sm text-stone mt-1">Coffee market intelligence for independent traders.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Product</strong>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<strong class="text-espresso text-sm">Product</strong>
|
||||
<ul class="list-none p-0 mt-1 space-y-1 text-sm">
|
||||
<li><a href="{{ url_for('public.features') }}">Features</a></li>
|
||||
<li><a href="{{ url_for('billing.pricing') }}">Pricing</a></li>
|
||||
<li><a href="{{ url_for('public.about') }}">About</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Legal</strong>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<strong class="text-espresso text-sm">Legal</strong>
|
||||
<ul class="list-none p-0 mt-1 space-y-1 text-sm">
|
||||
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
|
||||
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align: center; margin-top: 2rem;">
|
||||
<small>© {{ now.year }} {{ config.APP_NAME }}. All rights reserved.</small>
|
||||
<p class="text-center mt-8 text-xs text-stone">
|
||||
© {{ now.year }} {{ config.APP_NAME }}. All rights reserved.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<!-- HTMX (optional) -->
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user