diff --git a/Dockerfile b/Dockerfile
index 5d0ac86..88ff350 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,18 +1,30 @@
-FROM python:3.11-slim-buster
+# Use an official Python runtime as a base
+FROM python:3.11-slim-bookworm
-# Install build dependencies (build-essential provides gcc and other tools)
-RUN apt-get update && apt-get install -y build-essential
+# Set working directory
+WORKDIR /app
-WORKDIR /rideaware_landing
+# Install system dependencies
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+# Copy requirements first to leverage Docker cache
COPY requirements.txt .
+# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
+# Copy application code
COPY . .
-ENV FLASK_APP=server.py
+# Environment variables
+ENV FLASK_APP=app.py
+ENV FLASK_ENV=production
+ENV ENVIRONMENT=production
EXPOSE 5001
-CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"]
+# Use Gunicorn as production server
+CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"]
\ No newline at end of file
diff --git a/app.py b/app.py
index 8d6188e..85c05bd 100644
--- a/app.py
+++ b/app.py
@@ -1,7 +1,9 @@
import os
-import logging
import smtplib
from email.mime.text import MIMEText
+from functools import wraps
+from urllib.parse import urlparse, urljoin
+
from flask import (
Flask,
render_template,
@@ -11,135 +13,222 @@ from flask import (
flash,
session,
)
+from markupsafe import escape
from dotenv import load_dotenv
from werkzeug.security import check_password_hash
-from functools import wraps # Import wraps
-from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin
+
+from database import (
+ get_connection,
+ init_db,
+ get_all_emails,
+ get_admin,
+ create_default_admin,
+)
load_dotenv()
+
app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY")
-base_url = os.getenv("BASE_URL")
+base_url = os.getenv("BASE_URL", "").strip().strip("/")
-# SMTP settings (for sending update emails)
SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
-SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL
+SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER)
-# Logging setup
-logging.basicConfig(
- level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
-)
-logger = logging.getLogger(__name__)
-
-# Initialize the database and create default admin user if necessary.
init_db()
create_default_admin()
-# Decorator for requiring login
+
def login_required(f):
- @wraps(f) # Use wraps to preserve function metadata
+ @wraps(f)
def decorated_function(*args, **kwargs):
if "username" not in session:
- return redirect(url_for("login"))
+ next_url = request.full_path if request.query_string else request.path
+ return redirect(url_for("login", next=next_url))
return f(*args, **kwargs)
return decorated_function
-def send_update_email(subject, body, email):
- """Sends email, returns True on success, False on failure."""
+def get_dashboard_counts():
+ """Return dict of counts: total subscribers, total newsletters, sent today."""
+ counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0}
try:
- server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
- server.set_debuglevel(False) # Keep debug level at False for production
- server.login(SMTP_USER, SMTP_PASSWORD)
-
- unsub_link = f"https://{base_url}/unsubscribe?email={email}"
- custom_body = (
- f"{body}
"
- f"If you ever wish to unsubscribe, please click here"
- )
-
- msg = MIMEText(custom_body, "html", "utf-8")
- msg["Subject"] = subject
- msg["From"] = SENDER_EMAIL # Use sender email
- msg["To"] = email
-
- server.sendmail(SENDER_EMAIL, email, msg.as_string()) # Use sender email
-
- server.quit()
- logger.info(f"Update email sent to: {email}")
- return True
- except Exception as e:
- logger.error(f"Failed to send email to {email}: {e}")
- return False
-
-
-def process_send_update_email(subject, body):
- """Helper function to send an update email to all subscribers."""
- subscribers = get_all_emails()
- if not subscribers:
- return "No subscribers found."
- try:
- for email in subscribers:
- if not send_update_email(subject, body, email):
- return f"Failed to send to {email}" # Specific failure message
-
- # Log newsletter content for audit purposes
conn = get_connection()
- cursor = conn.cursor()
- cursor.execute(
- "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body)
- )
- conn.commit()
- cursor.close()
- conn.close()
+ cur = conn.cursor()
+ cur.execute("SELECT COUNT(*) FROM subscribers")
+ counts["total_subscribers"] = cur.fetchone()[0] or 0
+ cur.execute("SELECT COUNT(*) FROM newsletters")
+ counts["total_newsletters"] = cur.fetchone()[0] or 0
+
+ cur.execute(
+ """
+ SELECT COUNT(*)
+ FROM newsletters
+ WHERE sent_at::date = CURRENT_DATE
+ """
+ )
+ counts["sent_today"] = cur.fetchone()[0] or 0
+
+ cur.close()
+ conn.close()
+ except Exception:
+ pass
+ return counts
+
+
+def is_safe_url(target: str) -> bool:
+ if not target:
+ return False
+ ref = urlparse(request.host_url)
+ test = urlparse(urljoin(request.host_url, target))
+ return test.scheme in ("http", "https") and ref.netloc == test.netloc
+
+
+def send_update_email(subject: str, body_html: str, email: str) -> bool:
+ """Send a single HTML email with retries."""
+ max_retries = 3
+ retry_count = 0
+
+ unsub_link = ""
+ if base_url:
+ unsub_link = f"https://{base_url}/unsubscribe?email={email}"
+
+ if unsub_link:
+ custom_body = (
+ f"{body_html}"
+ f"
"
+ f"If you ever wish to unsubscribe, please click "
+ f"here."
+ )
+ else:
+ custom_body = body_html
+
+ while retry_count < max_retries:
+ try:
+ server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
+ server.set_debuglevel(0)
+ server.login(SMTP_USER, SMTP_PASSWORD)
+
+ msg = MIMEText(custom_body, "html", "utf-8")
+ msg["Subject"] = subject
+ msg["From"] = SENDER_EMAIL
+ msg["To"] = email
+
+ server.sendmail(SENDER_EMAIL, [email], msg.as_string())
+ server.quit()
+ return True
+ except Exception:
+ retry_count += 1
+ if retry_count >= max_retries:
+ break
+ import time
+
+ time.sleep(1.0)
+
+ return False
+
+
+def process_send_update_email(subject: str, body_html: str) -> str:
+ """Send update email to all subscribers and log newsletter content."""
+ try:
+ subscribers = get_all_emails()
+ if not subscribers:
+ return "No subscribers found."
+
+ failures = []
+ for email in subscribers:
+ if not send_update_email(subject, body_html, email):
+ failures.append(email)
+
+ try:
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT INTO newsletters (subject, body) VALUES (%s, %s)",
+ (subject, body_html),
+ )
+ conn.commit()
+ cursor.close()
+ conn.close()
+ except Exception:
+ pass
+
+ if failures:
+ return f"Sent with failures: {len(failures)} recipients failed."
return "Email has been sent to all subscribers."
except Exception as e:
- logger.exception("Error processing sending updates")
return f"Failed to send email: {e}"
-@app.route("/")
+@app.route("/", methods=["GET"])
@login_required
def index():
- """Displays all subscriber emails"""
- emails = get_all_emails()
- return render_template("admin_index.html", emails=emails)
+ """Dashboard: list subscriber emails and show widgets."""
+ emails = []
+ try:
+ emails = get_all_emails()
+ except Exception:
+ flash("Could not load subscribers right now.", "danger")
+
+ counts = get_dashboard_counts()
+ return render_template("admin_index.html", emails=emails, counts=counts)
+
@app.route("/send_update", methods=["GET", "POST"])
@login_required
def send_update():
- """Display a form to send an update email; process submission on POST."""
if request.method == "POST":
- subject = request.form["subject"]
- body = request.form["body"]
- result_message = process_send_update_email(subject, body)
- flash(result_message)
+ subject = (request.form.get("subject") or "").strip()
+ body_html = request.form.get("body") or ""
+
+ if not subject or not body_html:
+ flash("Subject and body are required", "danger")
+ return redirect(url_for("send_update"))
+
+ result_message = process_send_update_email(subject, body_html)
+ flash(escape(result_message))
return redirect(url_for("send_update"))
+
return render_template("send_update.html")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
- username = request.form.get("username")
- password = request.form.get("password")
+ username = (request.form.get("username") or "").strip()
+ password = (request.form.get("password") or "").strip()
+
+ if not username or not password:
+ flash("Username and password are required", "danger")
+ return redirect(url_for("login"))
+
admin = get_admin(username)
if admin and check_password_hash(admin[1], password):
session["username"] = username
+ session.permanent = True
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
+ app.config["SESSION_COOKIE_SECURE"] = True
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
+
+ next_url = request.args.get("next")
+ if next_url and is_safe_url(next_url):
+ flash("Logged in successfully", "success")
+ return redirect(next_url)
+
flash("Logged in successfully", "success")
return redirect(url_for("index"))
else:
flash("Invalid username or password", "danger")
return redirect(url_for("login"))
+
return render_template("login.html")
-@app.route("/logout")
+@app.route("/logout", methods=["GET"])
def logout():
session.pop("username", None)
flash("Logged out successfully", "success")
@@ -147,4 +236,9 @@ def logout():
if __name__ == "__main__":
- app.run(port=5001, debug=True)
+ is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production"
+ app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http"
+ if is_prod:
+ app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)
+ else:
+ app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True)
\ No newline at end of file
diff --git a/database.py b/database.py
index b90e0ef..e5a85fb 100644
--- a/database.py
+++ b/database.py
@@ -1,54 +1,53 @@
import os
-import logging
import psycopg2
-from psycopg2 import IntegrityError
+from psycopg2 import IntegrityError, pool, OperationalError
from dotenv import load_dotenv
from werkzeug.security import generate_password_hash
load_dotenv()
-# Logging setup
-logging.basicConfig(
- level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
-)
-logger = logging.getLogger(__name__)
+try:
+ DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1))
+ DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10))
+
+ conn_pool = pool.ThreadedConnectionPool(
+ minconn=DB_MIN_CONN,
+ maxconn=DB_MAX_CONN,
+ host=os.getenv("PG_HOST"),
+ port=os.getenv("PG_PORT"),
+ dbname=os.getenv("PG_DATABASE"),
+ user=os.getenv("PG_USER"),
+ password=os.getenv("PG_PASSWORD"),
+ connect_timeout=10,
+ )
+except OperationalError:
+ raise
+except Exception:
+ raise
def get_connection():
- """Return a new connection to the PostgreSQL database."""
- try:
- conn = psycopg2.connect(
- host=os.getenv("PG_HOST"),
- port=os.getenv("PG_PORT"),
- dbname=os.getenv("PG_DATABASE"),
- user=os.getenv("PG_USER"),
- password=os.getenv("PG_PASSWORD"),
- connect_timeout=10,
- )
- return conn
- except Exception as e:
- logger.error(f"Database connection error: {e}")
- raise
+ """Get a connection from the connection pool."""
+ return conn_pool.getconn()
def init_db():
- """Initialize the database tables."""
+ """Initialize database tables with connection pool."""
conn = None
+ cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
- # Create subscribers table (if not exists)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL
)
- """
+ """
)
- # Create admin_users table (if not exists)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS admin_users (
@@ -56,94 +55,96 @@ def init_db():
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
- """
+ """
)
- # Newsletter storage
cursor.execute(
"""
- CREATE TABLE IF NOT EXISTS newsletters (
- id SERIAL PRIMARY KEY,
- subject TEXT NOT NULL,
- body TEXT NOT NULL,
- sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
- )
- """
+ CREATE TABLE IF NOT EXISTS newsletters (
+ id SERIAL PRIMARY KEY,
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+ sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+ )
+ """
)
conn.commit()
- logger.info("Database initialized successfully.")
- except Exception as e:
- logger.error(f"Database initialization error: {e}")
+ except Exception:
if conn:
- conn.rollback() # Rollback if there's an error
-
+ conn.rollback()
raise
finally:
- if conn:
+ if cursor:
cursor.close()
- conn.close()
+ if conn:
+ conn_pool.putconn(conn)
def get_all_emails():
"""Return a list of all subscriber emails."""
+ conn = None
+ cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT email FROM subscribers")
results = cursor.fetchall()
- emails = [row[0] for row in results]
- logger.debug(f"Retrieved emails: {emails}")
- return emails
- except Exception as e:
- logger.error(f"Error retrieving emails: {e}")
+ return [row[0] for row in results]
+ except Exception:
return []
finally:
- if conn:
+ if cursor:
cursor.close()
- conn.close()
+ if conn:
+ conn_pool.putconn(conn)
def add_email(email):
"""Insert an email into the subscribers table."""
conn = None
+ cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
conn.commit()
- logger.info(f"Email {email} added successfully.")
return True
except IntegrityError:
- logger.warning(f"Attempted to add duplicate email: {email}")
+ if conn:
+ conn.rollback()
return False
- except Exception as e:
- logger.error(f"Error adding email {email}: {e}")
+ except Exception:
+ if conn:
+ conn.rollback()
return False
finally:
- if conn:
+ if cursor:
cursor.close()
- conn.close()
+ if conn:
+ conn_pool.putconn(conn)
def remove_email(email):
"""Remove an email from the subscribers table."""
conn = None
+ cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
rowcount = cursor.rowcount
conn.commit()
- logger.info(f"Email {email} removed successfully.")
return rowcount > 0
- except Exception as e:
- logger.error(f"Error removing email {email}: {e}")
+ except Exception:
+ if conn:
+ conn.rollback()
return False
finally:
- if conn:
+ if cursor:
cursor.close()
- conn.close()
+ if conn:
+ conn_pool.putconn(conn)
def get_admin(username):
@@ -151,6 +152,7 @@ def get_admin(username):
Returns a tuple (username, password_hash) if found, otherwise None.
"""
conn = None
+ cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
@@ -158,15 +160,14 @@ def get_admin(username):
"SELECT username, password FROM admin_users WHERE username = %s",
(username,),
)
- result = cursor.fetchone()
- return result # (username, password_hash)
- except Exception as e:
- logger.error(f"Error retrieving admin: {e}")
+ return cursor.fetchone()
+ except Exception:
return None
finally:
- if conn:
+ if cursor:
cursor.close()
- conn.close()
+ if conn:
+ conn_pool.putconn(conn)
def create_default_admin():
@@ -174,30 +175,60 @@ def create_default_admin():
default_username = os.getenv("ADMIN_USERNAME", "admin")
default_password = os.getenv("ADMIN_PASSWORD", "changeme")
hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256")
+
conn = None
+ cursor = None
try:
conn = get_connection()
cursor = conn.cursor()
- # Check if the admin already exists
cursor.execute(
"SELECT id FROM admin_users WHERE username = %s", (default_username,)
)
- if cursor.fetchone() is None:
+ exists = cursor.fetchone()
+ if exists is None:
cursor.execute(
"INSERT INTO admin_users (username, password) VALUES (%s, %s)",
(default_username, hashed_password),
)
conn.commit()
- logger.info("Default admin created successfully")
- else:
- logger.info("Default admin already exists")
- except Exception as e:
- logger.error(f"Error creating default admin: {e}")
+ except Exception:
if conn:
conn.rollback()
finally:
- if conn:
+ if cursor:
cursor.close()
- conn.close()
+ if conn:
+ conn_pool.putconn(conn)
+
+def close_pool():
+ """Close the database connection pool."""
+ try:
+ conn_pool.closeall()
+ except Exception:
+ pass
+
+
+class DatabaseContext:
+ """Optional context manager for manual transactions."""
+ def __init__(self):
+ self.conn = None
+ self.cursor = None
+
+ def __enter__(self):
+ self.conn = get_connection()
+ self.cursor = self.conn.cursor()
+ return self.cursor
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ try:
+ if exc_type:
+ self.conn.rollback()
+ else:
+ self.conn.commit()
+ finally:
+ if self.cursor:
+ self.cursor.close()
+ if self.conn:
+ conn_pool.putconn(self.conn)
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 26fcaf5..3d2a2e9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,6 @@ flask
python-dotenv
Werkzeug
psycopg2-binary
+psycopg2-pool
+python-decouple
+markupsafe
\ No newline at end of file
diff --git a/static/css/style.css b/static/css/style.css
index b9c3f88..9dca122 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1,55 +1,306 @@
+:root {
+ --primary: #2563eb;
+ --primary-hover: #1d4ed8;
+ --bg: #f5f7fb;
+ --bg-grad-1: #f8fbff;
+ --bg-grad-2: #eef3fb;
+ --surface: #ffffff;
+ --text: #0f172a;
+ --muted: #64748b;
+ --border: #e5e7eb;
+ --ring: rgba(37, 99, 235, 0.25);
+ --radius: 14px;
+ --shadow-1: 0 8px 24px rgba(15, 23, 42, 0.08);
+ --shadow-2: 0 14px 38px rgba(15, 23, 42, 0.12);
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+html, body {
+ margin: 0;
+ padding: 0;
+}
body {
- font-family: Arial, sans-serif;
- padding: 20px;
+ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
+ color: var(--text);
+ background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%),
+ radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%),
+ var(--bg);
+ min-height: 100vh;
+}
+
+.navbar {
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid rgba(15, 23, 42, 0.06);
+}
+.navbar-content {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 14px 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.brand {
+ color: var(--text);
+ font-weight: 700;
+ text-decoration: none;
+ letter-spacing: 0.2px;
+}
+.navbar-links a {
+ color: var(--muted);
+ text-decoration: none;
+ margin-left: 14px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ transition: all 0.2s ease;
+}
+.navbar-links a:hover {
+ color: var(--text);
+ background: rgba(15, 23, 42, 0.05);
+}
+.navbar-links .logout {
+ color: #b91c1c;
+}
+.navbar-links .logout:hover {
+ background: rgba(185, 28, 28, 0.08);
+}
+
+.container {
+ max-width: 1100px;
+ margin: 28px auto 48px;
+ padding: 0 20px;
+ padding-bottom: 64px;
+}
+
+.footer {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1000;
+ border-top: 1px solid rgba(15, 23, 42, 0.06);
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(8px);
+}
+
+.footer-inner {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 14px 20px;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.page-header {
+ margin-bottom: 18px;
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.page-title {
+ margin: 0 0 4px 0;
+ font-size: 26px;
+ font-weight: 700;
+}
+.page-subtitle {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+.page-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-1);
+ padding: 20px;
+}
+.empty-state {
+ text-align: center;
+ color: var(--muted);
+}
+
+.widgets {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ margin-bottom: 18px;
+}
+.widget-card {
+ background: linear-gradient(180deg, #fff, #fafcff);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-1);
+ padding: 18px;
+}
+.widget-label {
+ color: var(--muted);
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 8px;
+}
+.widget-value {
+ font-size: 28px;
+ font-weight: 800;
+ letter-spacing: 0.3px;
+ color: var(--text);
+}
+
+.table-wrap {
+ overflow: hidden;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+}
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ background: transparent;
+}
+.table thead th {
+ text-align: left;
+ font-weight: 600;
+ color: var(--muted);
+ font-size: 13px;
+ letter-spacing: 0.3px;
+ background: #f9fafb;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+}
+.table tbody td {
+ padding: 14px;
+ border-bottom: 1px solid #f1f5f9;
+}
+.table tbody tr:hover td {
+ background: #f9fbff;
+ transition: background 0.15s ease;
+}
+
+.form {
+ display: grid;
+ gap: 16px;
+}
+.form-group {
+ display: grid;
+ gap: 8px;
+}
+.form-group label {
+ font-weight: 600;
+ color: #334155;
+ font-size: 14px;
+}
+.form-group input,
+.form-group textarea {
+ width: 100%;
+ color: var(--text);
+ background: #ffffff;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 12px 14px;
+ font-size: 15px;
+ transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
+}
+.form-group textarea {
+ resize: vertical;
+ min-height: 160px;
+}
+.form-group input:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: #a7c2ff;
+ box-shadow: 0 0 0 4px var(--ring);
+ background: #ffffff;
+}
+.form-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+}
+
+.button {
+ appearance: none;
+ border: 1px solid var(--border);
+ background: #ffffff;
+ color: var(--text);
+ border-radius: 12px;
+ padding: 10px 14px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease;
+}
+.button:hover {
+ background: #f5f7fb;
+}
+.button:active {
+ transform: translateY(1px);
+}
+.button-primary {
+ color: #ffffff;
+ background: linear-gradient(180deg, #3b82f6, var(--primary));
+ border-color: rgba(37, 99, 235, 0.4);
+}
+.button-primary:hover {
+ background: linear-gradient(180deg, #2f74ed, var(--primary-hover));
+}
+.button-secondary {
+ background: #ffffff;
+}
+
+.flash-stack {
+ display: grid;
+ gap: 10px;
+ margin-bottom: 18px;
+}
+.flash {
+ border-radius: 12px;
+ padding: 12px 14px;
+ font-weight: 600;
+ border: 1px solid var(--border);
+ background: #ffffff;
+}
+.flash-success {
+ border-color: #a7f3d0;
+ background: #ecfdf5;
+ color: #065f46;
+}
+.flash-danger,
+.flash-error {
+ border-color: #fecaca;
+ background: #fef2f2;
+ color: #991b1b;
+}
+.flash-warning {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #92400e;
+}
+
+.auth-wrapper {
+ display: grid;
+ place-items: center;
+ min-height: calc(100vh - 120px);
+ padding-top: 40px;
+}
+.auth-card {
+ max-width: 420px;
+ width: 100%;
+}
+
+@media (max-width: 900px) {
+ .widgets {
+ grid-template-columns: 1fr;
}
-
- table {
- border-collapse: collapse;
- width: 100%;
- }
-
- th,
- td {
- border: 1px solid #ddd;
- padding: 8px;
- text-align: left;
- }
-
- th {
- background-color: #f2f2f2;
- }
-
- a {
- margin-right: 10px;
- }
-
- form {
- max-width: 600px;
- margin: 0 auto;
- }
-
- label {
- display: block;
- margin-top: 15px;
- }
-
- input[type="text"],
- input[type="password"],
- textarea {
- width: 100%;
- padding: 8px;
- }
-
- button {
- margin-top: 15px;
- padding: 10px 20px;
- }
-
- .flash {
- background-color: #f8d7da;
- color: #721c24;
- padding: 10px;
- margin-bottom: 10px;
- text-align: center;
- }
-
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/templates/admin_index.html b/templates/admin_index.html
index c4620af..bf57258 100644
--- a/templates/admin_index.html
+++ b/templates/admin_index.html
@@ -1,44 +1,53 @@
-
-
-
Quick overview of your mailing activity
+- Send Update Email| - Logout -
+ - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -| Email Address | -
|---|
| {{ email }} | -
| Email Address | +
|---|
| {{ email }} | +
No subscribers found.
- {% endif %} - - +No subscribers found.
+Sign in to manage your subscribers
- - - -Send an email update to all subscribers
+