Compare commits
	
		
			10 commits
		
	
	
		
			refactor/c
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 803471b914 | ||
|   | 797266226b | ||
|   | 17fe0bf79f | ||
|   | f2d225c56a | ||
|   | 941a3dabc9 | ||
|   | 49ab3c1fe4 | ||
|   | 4f059fd0e1 | ||
|   | d9c86aa1bb | ||
|   | a8589b659f | ||
|   | e36bdc4568 | 
					 10 changed files with 852 additions and 2785 deletions
				
			
		
							
								
								
									
										24
									
								
								Dockerfile
									
										
									
									
									
								
							
							
						
						
									
										24
									
								
								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) | # Set working directory | ||||||
| RUN apt-get update && apt-get install -y build-essential | 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 . | COPY requirements.txt . | ||||||
| 
 | 
 | ||||||
|  | # Install Python dependencies | ||||||
| RUN pip install --no-cache-dir -r requirements.txt | RUN pip install --no-cache-dir -r requirements.txt | ||||||
| 
 | 
 | ||||||
|  | # Copy application code | ||||||
| COPY . . | COPY . . | ||||||
| 
 | 
 | ||||||
| ENV FLASK_APP=server.py | # Environment variables | ||||||
|  | ENV FLASK_APP=app.py | ||||||
|  | ENV FLASK_ENV=production | ||||||
|  | ENV ENVIRONMENT=production | ||||||
| 
 | 
 | ||||||
| EXPOSE 5001 | 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"] | ||||||
							
								
								
									
										490
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										490
									
								
								app.py
									
										
									
									
									
								
							|  | @ -1,10 +1,9 @@ | ||||||
| import os | import os | ||||||
| import logging |  | ||||||
| import smtplib | import smtplib | ||||||
| from email.mime.text import MIMEText | from email.mime.text import MIMEText | ||||||
| from email.mime.multipart import MIMEMultipart | from functools import wraps | ||||||
| import concurrent.futures | from urllib.parse import urlparse, urljoin | ||||||
| from threading import Lock | 
 | ||||||
| from flask import ( | from flask import ( | ||||||
|     Flask, |     Flask, | ||||||
|     render_template, |     render_template, | ||||||
|  | @ -13,402 +12,233 @@ from flask import ( | ||||||
|     url_for, |     url_for, | ||||||
|     flash, |     flash, | ||||||
|     session, |     session, | ||||||
|     jsonify, |  | ||||||
|     abort |  | ||||||
| ) | ) | ||||||
|  | from markupsafe import escape | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from werkzeug.security import check_password_hash | from werkzeug.security import check_password_hash | ||||||
| from functools import wraps | 
 | ||||||
| import re |  | ||||||
| from database import ( | from database import ( | ||||||
|     get_db_connection, init_db, get_all_emails, get_admin, create_default_admin, |     get_connection, | ||||||
|     get_subscriber_stats, add_email, remove_email, save_newsletter, |     init_db, | ||||||
|     update_newsletter_stats, log_email_delivery, get_recent_newsletters |     get_all_emails, | ||||||
|  |     get_admin, | ||||||
|  |     create_default_admin, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| load_dotenv() | load_dotenv() | ||||||
|  | 
 | ||||||
| app = Flask(__name__) | app = Flask(__name__) | ||||||
| app.secret_key = os.getenv("SECRET_KEY") | app.secret_key = os.getenv("SECRET_KEY") | ||||||
| base_url = os.getenv("BASE_URL") | base_url = os.getenv("BASE_URL", "").strip().strip("/") | ||||||
| 
 | 
 | ||||||
| # SMTP settings |  | ||||||
| SMTP_SERVER = os.getenv("SMTP_SERVER") | SMTP_SERVER = os.getenv("SMTP_SERVER") | ||||||
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | ||||||
| SMTP_USER = os.getenv("SMTP_USER") | SMTP_USER = os.getenv("SMTP_USER") | ||||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | ||||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) | SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) | ||||||
| SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin") |  | ||||||
| 
 | 
 | ||||||
| # Email sending configuration |  | ||||||
| MAX_EMAIL_WORKERS = 5 |  | ||||||
| email_send_lock = Lock() |  | ||||||
| 
 |  | ||||||
| # 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 |  | ||||||
| init_db() | init_db() | ||||||
| create_default_admin() | create_default_admin() | ||||||
| 
 | 
 | ||||||
| # Security decorators | 
 | ||||||
| def login_required(f): | def login_required(f): | ||||||
|     @wraps(f) |     @wraps(f) | ||||||
|     def decorated_function(*args, **kwargs): |     def decorated_function(*args, **kwargs): | ||||||
|         if "username" not in session: |         if "username" not in session: | ||||||
|             if request.is_json: |             next_url = request.full_path if request.query_string else request.path | ||||||
|                 return jsonify({"error": "Authentication required"}), 401 |             return redirect(url_for("login", next=next_url)) | ||||||
|             flash("Please log in to access this page.", "warning") |  | ||||||
|             return redirect(url_for("login")) |  | ||||||
|         return f(*args, **kwargs) |         return f(*args, **kwargs) | ||||||
|  | 
 | ||||||
|     return decorated_function |     return decorated_function | ||||||
| 
 | 
 | ||||||
| def validate_email(email): |  | ||||||
|     """Validate email format.""" |  | ||||||
|     pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' |  | ||||||
|     return re.match(pattern, email) is not None |  | ||||||
| 
 | 
 | ||||||
| def send_single_email(email_data): | def get_dashboard_counts(): | ||||||
|     """Send a single email (for thread pool execution).""" |     """Return dict of counts: total subscribers, total newsletters, sent today.""" | ||||||
|     email, subject, body, newsletter_id = email_data |     counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} | ||||||
|     try: |     try: | ||||||
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15) |         conn = get_connection() | ||||||
|         server.login(SMTP_USER, SMTP_PASSWORD) |         cur = conn.cursor() | ||||||
|  |         cur.execute("SELECT COUNT(*) FROM subscribers") | ||||||
|  |         counts["total_subscribers"] = cur.fetchone()[0] or 0 | ||||||
| 
 | 
 | ||||||
|         # Create message |         cur.execute("SELECT COUNT(*) FROM newsletters") | ||||||
|         msg = MIMEMultipart('alternative') |         counts["total_newsletters"] = cur.fetchone()[0] or 0 | ||||||
|         msg['Subject'] = subject |  | ||||||
|         msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>" |  | ||||||
|         msg['To'] = email |  | ||||||
| 
 | 
 | ||||||
|         # Create unsubscribe link |         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}" |         unsub_link = f"https://{base_url}/unsubscribe?email={email}" | ||||||
| 
 | 
 | ||||||
|         # HTML body with unsubscribe link |     if unsub_link: | ||||||
|         html_body = f""" |         custom_body = ( | ||||||
|         <html> |             f"{body_html}" | ||||||
|         <body> |             f"<br><br>" | ||||||
|         {body} |             f"If you ever wish to unsubscribe, please click " | ||||||
|         <hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;"> |             f"<a href='{unsub_link}'>here</a>." | ||||||
|         <p style="font-size: 12px; color: #888; text-align: center;"> |         ) | ||||||
|         If you wish to unsubscribe, please <a href="{unsub_link}" style="color: #888;">click here</a> |     else: | ||||||
|         </p> |         custom_body = body_html | ||||||
|         </body> |  | ||||||
|         </html> |  | ||||||
|         """ |  | ||||||
| 
 | 
 | ||||||
|         # Attach HTML part |     while retry_count < max_retries: | ||||||
|         html_part = MIMEText(html_body, 'html') |         try: | ||||||
|         msg.attach(html_part) |             server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) | ||||||
|  |             server.set_debuglevel(0) | ||||||
|  |             server.login(SMTP_USER, SMTP_PASSWORD) | ||||||
| 
 | 
 | ||||||
|         server.sendmail(SENDER_EMAIL, email, msg.as_string()) |             msg = MIMEText(custom_body, "html", "utf-8") | ||||||
|         server.quit() |             msg["Subject"] = subject | ||||||
|  |             msg["From"] = SENDER_EMAIL | ||||||
|  |             msg["To"] = email | ||||||
| 
 | 
 | ||||||
|         # Log successful delivery |             server.sendmail(SENDER_EMAIL, [email], msg.as_string()) | ||||||
|         if newsletter_id: |             server.quit() | ||||||
|             log_email_delivery(newsletter_id, email, 'sent') |             return True | ||||||
|  |         except Exception: | ||||||
|  |             retry_count += 1 | ||||||
|  |             if retry_count >= max_retries: | ||||||
|  |                 break | ||||||
|  |             import time | ||||||
| 
 | 
 | ||||||
|         logger.info(f"Email sent successfully to: {email}") |             time.sleep(1.0) | ||||||
|         return True, email, None | 
 | ||||||
|  |     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: |     except Exception as e: | ||||||
|         error_msg = str(e) |         return f"Failed to send email: {e}" | ||||||
|         logger.error(f"Failed to send email to {email}: {error_msg}") |  | ||||||
| 
 | 
 | ||||||
|         # Log failed delivery |  | ||||||
|         if newsletter_id: |  | ||||||
|             log_email_delivery(newsletter_id, email, 'failed', error_msg) |  | ||||||
| 
 | 
 | ||||||
|         return False, email, error_msg | @app.route("/", methods=["GET"]) | ||||||
| 
 |  | ||||||
| def send_newsletter_batch(subject, body, email_list, newsletter_id=None): |  | ||||||
|     """Send newsletter to multiple recipients using thread pool.""" |  | ||||||
|     success_count = 0 |  | ||||||
|     failure_count = 0 |  | ||||||
|     failed_emails = [] |  | ||||||
|      |  | ||||||
|     # Prepare email data for thread pool |  | ||||||
|     email_data_list = [(email, subject, body, newsletter_id) for email in email_list] |  | ||||||
|      |  | ||||||
|     with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor: |  | ||||||
|         future_to_email = { |  | ||||||
|             executor.submit(send_single_email, email_data): email_data[0]  |  | ||||||
|             for email_data in email_data_list |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         for future in concurrent.futures.as_completed(future_to_email): |  | ||||||
|             success, email, error = future.result() |  | ||||||
|             if success: |  | ||||||
|                 success_count += 1 |  | ||||||
|             else: |  | ||||||
|                 failure_count += 1 |  | ||||||
|                 failed_emails.append({'email': email, 'error': error}) |  | ||||||
|      |  | ||||||
|     return success_count, failure_count, failed_emails |  | ||||||
| 
 |  | ||||||
| @app.route("/") |  | ||||||
| @login_required | @login_required | ||||||
| def index(): | def index(): | ||||||
|     """Dashboard with subscriber statistics and recent activity.""" |     """Dashboard: list subscriber emails and show widgets.""" | ||||||
|     page = request.args.get('page', 1, type=int) |     emails = [] | ||||||
|     search = request.args.get('search', '') |  | ||||||
|     per_page = 25 |  | ||||||
|      |  | ||||||
|     stats = get_subscriber_stats() |  | ||||||
|     subscribers_data = get_all_emails(page=page, per_page=per_page, search=search) |  | ||||||
|     recent_newsletters = get_recent_newsletters(limit=5) |  | ||||||
|      |  | ||||||
|     return render_template( |  | ||||||
|         "admin_index.html",  |  | ||||||
|         stats=stats, |  | ||||||
|         subscribers=subscribers_data['subscribers'], |  | ||||||
|         pagination={ |  | ||||||
|             'page': subscribers_data['page'], |  | ||||||
|             'per_page': subscribers_data['per_page'], |  | ||||||
|             'total_pages': subscribers_data['total_pages'], |  | ||||||
|             'total_count': subscribers_data['total_count'] |  | ||||||
|         }, |  | ||||||
|         search=search, |  | ||||||
|         recent_newsletters=recent_newsletters |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| @app.route("/subscribers") |  | ||||||
| @login_required |  | ||||||
| def subscribers(): |  | ||||||
|     """Detailed subscriber management page.""" |  | ||||||
|     page = request.args.get('page', 1, type=int) |  | ||||||
|     search = request.args.get('search', '') |  | ||||||
|     per_page = 50 |  | ||||||
|      |  | ||||||
|     subscribers_data = get_all_emails(page=page, per_page=per_page, search=search) |  | ||||||
|      |  | ||||||
|     return render_template( |  | ||||||
|         "subscribers.html", |  | ||||||
|         subscribers=subscribers_data['subscribers'], |  | ||||||
|         pagination={ |  | ||||||
|             'page': subscribers_data['page'], |  | ||||||
|             'per_page': subscribers_data['per_page'], |  | ||||||
|             'total_pages': subscribers_data['total_pages'], |  | ||||||
|             'total_count': subscribers_data['total_count'] |  | ||||||
|         }, |  | ||||||
|         search=search |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
| @app.route("/add_subscriber", methods=["POST"]) |  | ||||||
| @login_required |  | ||||||
| def add_subscriber(): |  | ||||||
|     """Add a new subscriber via AJAX.""" |  | ||||||
|     try: |     try: | ||||||
|         data = request.get_json() |         emails = get_all_emails() | ||||||
|         email = data.get('email', '').strip().lower() |     except Exception: | ||||||
|  |         flash("Could not load subscribers right now.", "danger") | ||||||
| 
 | 
 | ||||||
|         if not email or not validate_email(email): |     counts = get_dashboard_counts() | ||||||
|             return jsonify({"success": False, "message": "Invalid email format"}), 400 |     return render_template("admin_index.html", emails=emails, counts=counts) | ||||||
| 
 | 
 | ||||||
|         success = add_email(email, source='admin_manual') |  | ||||||
| 
 | 
 | ||||||
|         if success: | @app.route("/send_update", methods=["GET", "POST"]) | ||||||
|             return jsonify({"success": True, "message": f"Successfully added {email}"}) |  | ||||||
|         else: |  | ||||||
|             return jsonify({"success": False, "message": "Email already exists or failed to add"}), 400 |  | ||||||
|              |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error adding subscriber: {e}") |  | ||||||
|         return jsonify({"success": False, "message": "Server error occurred"}), 500 |  | ||||||
| 
 |  | ||||||
| @app.route("/remove_subscriber", methods=["POST"]) |  | ||||||
| @login_required | @login_required | ||||||
| def remove_subscriber(): | def send_update(): | ||||||
|     """Remove/unsubscribe a subscriber via AJAX.""" |  | ||||||
|     try: |  | ||||||
|         data = request.get_json() |  | ||||||
|         email = data.get('email', '').strip().lower() |  | ||||||
|          |  | ||||||
|         if not email: |  | ||||||
|             return jsonify({"success": False, "message": "Email is required"}), 400 |  | ||||||
|          |  | ||||||
|         success = remove_email(email) |  | ||||||
|          |  | ||||||
|         if success: |  | ||||||
|             return jsonify({"success": True, "message": f"Successfully unsubscribed {email}"}) |  | ||||||
|         else: |  | ||||||
|             return jsonify({"success": False, "message": "Email not found"}), 404 |  | ||||||
|              |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error removing subscriber: {e}") |  | ||||||
|         return jsonify({"success": False, "message": "Server error occurred"}), 500 |  | ||||||
| 
 |  | ||||||
| @app.route("/send_newsletter", methods=["GET", "POST"]) |  | ||||||
| @login_required |  | ||||||
| def send_newsletter(): |  | ||||||
|     """Enhanced newsletter sending with preview and batch processing.""" |  | ||||||
|     if request.method == "POST": |     if request.method == "POST": | ||||||
|         action = request.form.get('action', 'send') |         subject = (request.form.get("subject") or "").strip() | ||||||
|         subject = request.form.get('subject', '').strip() |         body_html = request.form.get("body") or "" | ||||||
|         body = request.form.get('body', '').strip() |  | ||||||
| 
 | 
 | ||||||
|         if not subject or not body: |         if not subject or not body_html: | ||||||
|             flash("Subject and body are required", "error") |             flash("Subject and body are required", "danger") | ||||||
|             return redirect(url_for('send_newsletter')) |             return redirect(url_for("send_update")) | ||||||
| 
 | 
 | ||||||
|         if action == 'preview': |         result_message = process_send_update_email(subject, body_html) | ||||||
|             # Return preview |         flash(escape(result_message)) | ||||||
|             preview_html = f""" |         return redirect(url_for("send_update")) | ||||||
|             <div class="email-preview"> |  | ||||||
|                 <h3>Subject: {subject}</h3> |  | ||||||
|                 <div class="email-body">{body}</div> |  | ||||||
|                 <hr> |  | ||||||
|                 <p><small>Unsubscribe link will be automatically added to all emails</small></p> |  | ||||||
|             </div> |  | ||||||
|             """ |  | ||||||
|             return render_template("send_newsletter.html", preview=preview_html, subject=subject, body=body) |  | ||||||
| 
 | 
 | ||||||
|         elif action == 'send': |     return render_template("send_update.html") | ||||||
|             # Get all active subscribers (backwards compatible) |  | ||||||
|             try: |  | ||||||
|                 with get_db_connection() as conn: |  | ||||||
|                     cursor = conn.cursor() |  | ||||||
| 
 | 
 | ||||||
|                     # Check if status column exists for backwards compatibility |  | ||||||
|                     cursor.execute( |  | ||||||
|                         """ |  | ||||||
|                         SELECT EXISTS ( |  | ||||||
|                             SELECT FROM information_schema.columns  |  | ||||||
|                             WHERE table_name = 'subscribers' AND column_name = 'status' |  | ||||||
|                         ) |  | ||||||
|                         """ |  | ||||||
|                     ) |  | ||||||
|                     has_status = cursor.fetchone()[0] |  | ||||||
|                      |  | ||||||
|                     if has_status: |  | ||||||
|                         cursor.execute("SELECT email FROM subscribers WHERE status = 'active'") |  | ||||||
|                     else: |  | ||||||
|                         cursor.execute("SELECT email FROM subscribers") |  | ||||||
|                      |  | ||||||
|                     email_list = [row[0] for row in cursor.fetchall()] |  | ||||||
|                      |  | ||||||
|             except Exception as e: |  | ||||||
|                 logger.error(f"Error fetching subscriber emails: {e}") |  | ||||||
|                 flash("Error retrieving subscriber list", "error") |  | ||||||
|                 return redirect(url_for('send_newsletter')) |  | ||||||
|              |  | ||||||
|             if not email_list: |  | ||||||
|                 flash("No active subscribers found", "warning") |  | ||||||
|                 return redirect(url_for('send_newsletter')) |  | ||||||
|              |  | ||||||
|             # Save newsletter to database |  | ||||||
|             newsletter_id = save_newsletter(subject, body, session['username'], len(email_list)) |  | ||||||
|              |  | ||||||
|             # Send emails in batches |  | ||||||
|             try: |  | ||||||
|                 success_count, failure_count, failed_emails = send_newsletter_batch( |  | ||||||
|                     subject, body, email_list, newsletter_id |  | ||||||
|                 ) |  | ||||||
|                  |  | ||||||
|                 # Update newsletter statistics |  | ||||||
|                 if newsletter_id: |  | ||||||
|                     update_newsletter_stats(newsletter_id, success_count, failure_count) |  | ||||||
|                  |  | ||||||
|                 # Flash results |  | ||||||
|                 if success_count > 0: |  | ||||||
|                     flash(f"Newsletter sent successfully to {success_count} subscribers!", "success") |  | ||||||
|                  |  | ||||||
|                 if failure_count > 0: |  | ||||||
|                     flash(f"Failed to send to {failure_count} subscribers", "error") |  | ||||||
|                     for failed in failed_emails[:5]:  # Show first 5 failures |  | ||||||
|                         flash(f"Failed: {failed['email']} - {failed['error'][:100]}", "warning") |  | ||||||
|                  |  | ||||||
|             except Exception as e: |  | ||||||
|                 logger.error(f"Error sending newsletter: {e}") |  | ||||||
|                 flash(f"Error sending newsletter: {str(e)}", "error") |  | ||||||
|              |  | ||||||
|             return redirect(url_for('send_newsletter')) |  | ||||||
|      |  | ||||||
|     return render_template("send_newsletter.html") |  | ||||||
| 
 |  | ||||||
| @app.route("/newsletter_history") |  | ||||||
| @login_required |  | ||||||
| def newsletter_history(): |  | ||||||
|     """View newsletter sending history.""" |  | ||||||
|     newsletters = get_recent_newsletters(limit=50) |  | ||||||
|     return render_template("newsletter_history.html", newsletters=newsletters) |  | ||||||
| 
 | 
 | ||||||
| @app.route("/login", methods=["GET", "POST"]) | @app.route("/login", methods=["GET", "POST"]) | ||||||
| def login(): | def login(): | ||||||
|     if request.method == "POST": |     if request.method == "POST": | ||||||
|         username = request.form.get("username", "").strip() |         username = (request.form.get("username") or "").strip() | ||||||
|         password = request.form.get("password", "") |         password = (request.form.get("password") or "").strip() | ||||||
| 
 | 
 | ||||||
|         if not username or not password: |         if not username or not password: | ||||||
|             flash("Username and password are required", "error") |             flash("Username and password are required", "danger") | ||||||
|             return redirect(url_for("login")) |             return redirect(url_for("login")) | ||||||
| 
 | 
 | ||||||
|         admin = get_admin(username) |         admin = get_admin(username) | ||||||
|         if admin and len(admin) >= 3 and check_password_hash(admin[2], password): |         if admin and check_password_hash(admin[1], password): | ||||||
|             if len(admin) >= 4 and not admin[3]:  # Check is_active |  | ||||||
|                 flash("Account is disabled", "error") |  | ||||||
|                 return redirect(url_for("login")) |  | ||||||
|                  |  | ||||||
|             session["username"] = username |             session["username"] = username | ||||||
|             session["admin_id"] = admin[0] |             session.permanent = True | ||||||
|             flash("Logged in successfully!", "success") |             app.config["SESSION_COOKIE_HTTPONLY"] = True | ||||||
|  |             app.config["SESSION_COOKIE_SECURE"] = True | ||||||
|  |             app.config["SESSION_COOKIE_SAMESITE"] = "Lax" | ||||||
| 
 | 
 | ||||||
|             # Redirect to intended page or dashboard |             next_url = request.args.get("next") | ||||||
|             next_page = request.args.get('next') |             if next_url and is_safe_url(next_url): | ||||||
|             if next_page: |                 flash("Logged in successfully", "success") | ||||||
|                 return redirect(next_page) |                 return redirect(next_url) | ||||||
|  | 
 | ||||||
|  |             flash("Logged in successfully", "success") | ||||||
|             return redirect(url_for("index")) |             return redirect(url_for("index")) | ||||||
|         else: |         else: | ||||||
|             flash("Invalid username or password", "error") |             flash("Invalid username or password", "danger") | ||||||
|             return redirect(url_for("login")) |             return redirect(url_for("login")) | ||||||
| 
 | 
 | ||||||
|     return render_template("login.html") |     return render_template("login.html") | ||||||
| 
 | 
 | ||||||
| @app.route("/logout") | 
 | ||||||
|  | @app.route("/logout", methods=["GET"]) | ||||||
| def logout(): | def logout(): | ||||||
|     session.clear() |     session.pop("username", None) | ||||||
|     flash("You have been logged out successfully", "info") |     flash("Logged out successfully", "success") | ||||||
|     return redirect(url_for("login")) |     return redirect(url_for("login")) | ||||||
| 
 | 
 | ||||||
| # Public unsubscribe endpoint |  | ||||||
| @app.route("/unsubscribe") |  | ||||||
| def unsubscribe(): |  | ||||||
|     """Public unsubscribe endpoint.""" |  | ||||||
|     email = request.args.get('email', '').strip().lower() |  | ||||||
|      |  | ||||||
|     if not email or not validate_email(email): |  | ||||||
|         return render_template("unsubscribe.html", error="Invalid email address") |  | ||||||
|      |  | ||||||
|     success = remove_email(email) |  | ||||||
|      |  | ||||||
|     if success: |  | ||||||
|         return render_template("unsubscribe.html", success=True, email=email) |  | ||||||
|     else: |  | ||||||
|         return render_template("unsubscribe.html", error="Email not found or already unsubscribed") |  | ||||||
| 
 |  | ||||||
| # API endpoints for AJAX requests |  | ||||||
| @app.route("/api/stats") |  | ||||||
| @login_required |  | ||||||
| def api_stats(): |  | ||||||
|     """API endpoint for dashboard statistics.""" |  | ||||||
|     stats = get_subscriber_stats() |  | ||||||
|     return jsonify(stats) |  | ||||||
| 
 |  | ||||||
| # Error handlers |  | ||||||
| @app.errorhandler(404) |  | ||||||
| def not_found(error): |  | ||||||
|     return render_template("error.html", error="Page not found", code=404), 404 |  | ||||||
| 
 |  | ||||||
| @app.errorhandler(500) |  | ||||||
| def internal_error(error): |  | ||||||
|     logger.error(f"Internal error: {error}") |  | ||||||
|     return render_template("error.html", error="Internal server error", code=500), 500 |  | ||||||
| 
 |  | ||||||
| # Context processors |  | ||||||
| @app.context_processor |  | ||||||
| def inject_user(): |  | ||||||
|     return dict(current_user=session.get('username')) |  | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     app.run(host="0.0.0.0", 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) | ||||||
							
								
								
									
										648
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										648
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,427 +1,174 @@ | ||||||
| import os | import os | ||||||
| import logging |  | ||||||
| import psycopg2 | import psycopg2 | ||||||
| from psycopg2 import IntegrityError, pool | from psycopg2 import IntegrityError, pool, OperationalError | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from werkzeug.security import generate_password_hash | from werkzeug.security import generate_password_hash | ||||||
| from contextlib import contextmanager |  | ||||||
| from datetime import datetime, timezone |  | ||||||
| 
 | 
 | ||||||
| load_dotenv() | load_dotenv() | ||||||
| 
 | 
 | ||||||
| # Logging setup | try: | ||||||
| logging.basicConfig( |     DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) | ||||||
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" |     DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) | ||||||
| ) |  | ||||||
| logger = logging.getLogger(__name__) |  | ||||||
| 
 | 
 | ||||||
| # Connection pool for better performance |     conn_pool = pool.ThreadedConnectionPool( | ||||||
| connection_pool = None |         minconn=DB_MIN_CONN, | ||||||
| 
 |         maxconn=DB_MAX_CONN, | ||||||
| def init_connection_pool(): |         host=os.getenv("PG_HOST"), | ||||||
|     """Initialize the connection pool.""" |         port=os.getenv("PG_PORT"), | ||||||
|     global connection_pool |         dbname=os.getenv("PG_DATABASE"), | ||||||
|     try: |         user=os.getenv("PG_USER"), | ||||||
|         connection_pool = psycopg2.pool.ThreadedConnectionPool( |         password=os.getenv("PG_PASSWORD"), | ||||||
|             1, 20,  # min and max connections |         connect_timeout=10, | ||||||
|             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"), |  | ||||||
|         ) |  | ||||||
|         logger.info("Connection pool created successfully") |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Connection pool creation error: {e}") |  | ||||||
|         raise |  | ||||||
| 
 |  | ||||||
| @contextmanager |  | ||||||
| def get_db_connection(): |  | ||||||
|     """Context manager for database connections.""" |  | ||||||
|     if connection_pool is None: |  | ||||||
|         init_connection_pool() |  | ||||||
|      |  | ||||||
|     conn = None |  | ||||||
|     try: |  | ||||||
|         conn = connection_pool.getconn() |  | ||||||
|         yield conn |  | ||||||
|     except Exception as e: |  | ||||||
|         if conn: |  | ||||||
|             conn.rollback() |  | ||||||
|         logger.error(f"Database operation error: {e}") |  | ||||||
|         raise |  | ||||||
|     finally: |  | ||||||
|         if conn: |  | ||||||
|             connection_pool.putconn(conn) |  | ||||||
| 
 |  | ||||||
| def check_column_exists(cursor, table_name, column_name): |  | ||||||
|     """Check if a column exists in a table.""" |  | ||||||
|     cursor.execute( |  | ||||||
|         """ |  | ||||||
|         SELECT EXISTS ( |  | ||||||
|             SELECT FROM information_schema.columns  |  | ||||||
|             WHERE table_name = %s AND column_name = %s |  | ||||||
|         ) |  | ||||||
|         """, |  | ||||||
|         (table_name, column_name) |  | ||||||
|     ) |     ) | ||||||
|     return cursor.fetchone()[0] | except OperationalError: | ||||||
|  |     raise | ||||||
|  | except Exception: | ||||||
|  |     raise | ||||||
| 
 | 
 | ||||||
| def migrate_database(conn): |  | ||||||
|     """Apply database migrations to upgrade existing schema.""" |  | ||||||
|     cursor = conn.cursor() |  | ||||||
| 
 | 
 | ||||||
|     # Migration 1: Add new columns to subscribers table | def get_connection(): | ||||||
|     if not check_column_exists(cursor, 'subscribers', 'subscribed_at'): |     """Get a connection from the connection pool.""" | ||||||
|         logger.info("Adding subscribed_at column to subscribers table") |     return conn_pool.getconn() | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE subscribers ADD COLUMN subscribed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP" |  | ||||||
|         ) |  | ||||||
| 
 | 
 | ||||||
|     if not check_column_exists(cursor, 'subscribers', 'status'): |  | ||||||
|         logger.info("Adding status column to subscribers table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE subscribers ADD COLUMN status TEXT DEFAULT 'active'" |  | ||||||
|         ) |  | ||||||
|         # Add check constraint |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE subscribers ADD CONSTRAINT subscribers_status_check CHECK (status IN ('active', 'unsubscribed'))" |  | ||||||
|         ) |  | ||||||
|         # Update existing rows |  | ||||||
|         cursor.execute("UPDATE subscribers SET status = 'active' WHERE status IS NULL") |  | ||||||
|      |  | ||||||
|     if not check_column_exists(cursor, 'subscribers', 'source'): |  | ||||||
|         logger.info("Adding source column to subscribers table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE subscribers ADD COLUMN source TEXT DEFAULT 'manual'" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     # Migration 2: Add new columns to admin_users table |  | ||||||
|     if not check_column_exists(cursor, 'admin_users', 'created_at'): |  | ||||||
|         logger.info("Adding created_at column to admin_users table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE admin_users ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     if not check_column_exists(cursor, 'admin_users', 'last_login'): |  | ||||||
|         logger.info("Adding last_login column to admin_users table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE admin_users ADD COLUMN last_login TIMESTAMP WITH TIME ZONE" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     if not check_column_exists(cursor, 'admin_users', 'is_active'): |  | ||||||
|         logger.info("Adding is_active column to admin_users table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE admin_users ADD COLUMN is_active BOOLEAN DEFAULT TRUE" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     # Migration 3: Add new columns to newsletters table |  | ||||||
|     if not check_column_exists(cursor, 'newsletters', 'sent_by'): |  | ||||||
|         logger.info("Adding sent_by column to newsletters table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE newsletters ADD COLUMN sent_by TEXT" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     if not check_column_exists(cursor, 'newsletters', 'recipient_count'): |  | ||||||
|         logger.info("Adding recipient_count column to newsletters table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE newsletters ADD COLUMN recipient_count INTEGER DEFAULT 0" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     if not check_column_exists(cursor, 'newsletters', 'success_count'): |  | ||||||
|         logger.info("Adding success_count column to newsletters table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE newsletters ADD COLUMN success_count INTEGER DEFAULT 0" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     if not check_column_exists(cursor, 'newsletters', 'failure_count'): |  | ||||||
|         logger.info("Adding failure_count column to newsletters table") |  | ||||||
|         cursor.execute( |  | ||||||
|             "ALTER TABLE newsletters ADD COLUMN failure_count INTEGER DEFAULT 0" |  | ||||||
|         ) |  | ||||||
|      |  | ||||||
|     conn.commit() |  | ||||||
|     logger.info("Database migrations completed successfully") |  | ||||||
| 
 | 
 | ||||||
| def init_db(): | def init_db(): | ||||||
|     """Initialize the database tables with improved schema.""" |     """Initialize database tables with connection pool.""" | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         with get_db_connection() as conn: |         conn = get_connection() | ||||||
|             cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
| 
 | 
 | ||||||
|             # Create basic subscribers table (backwards compatible) |         cursor.execute( | ||||||
|             cursor.execute( |  | ||||||
|                 """ |  | ||||||
|                 CREATE TABLE IF NOT EXISTS subscribers ( |  | ||||||
|                     id SERIAL PRIMARY KEY, |  | ||||||
|                     email TEXT UNIQUE NOT NULL |  | ||||||
|                 ) |  | ||||||
|             """ |             """ | ||||||
|  |             CREATE TABLE IF NOT EXISTS subscribers ( | ||||||
|  |                 id SERIAL PRIMARY KEY, | ||||||
|  |                 email TEXT UNIQUE NOT NULL | ||||||
|             ) |             ) | ||||||
| 
 |  | ||||||
|             # Create basic admin_users table (backwards compatible) |  | ||||||
|             cursor.execute( |  | ||||||
|                 """ |  | ||||||
|                 CREATE TABLE IF NOT EXISTS admin_users ( |  | ||||||
|                     id SERIAL PRIMARY KEY, |  | ||||||
|                     username TEXT UNIQUE NOT NULL, |  | ||||||
|                     password TEXT NOT NULL |  | ||||||
|                 ) |  | ||||||
|             """ |             """ | ||||||
|             ) |         ) | ||||||
| 
 | 
 | ||||||
|             # Create basic newsletters table (backwards compatible) |         cursor.execute( | ||||||
|             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 admin_users ( | ||||||
|  |                 id SERIAL PRIMARY KEY, | ||||||
|  |                 username TEXT UNIQUE NOT NULL, | ||||||
|  |                 password TEXT NOT NULL | ||||||
|             ) |             ) | ||||||
| 
 |  | ||||||
|             # Email delivery tracking (new table) |  | ||||||
|             cursor.execute( |  | ||||||
|                 """ |  | ||||||
|                 CREATE TABLE IF NOT EXISTS email_deliveries ( |  | ||||||
|                     id SERIAL PRIMARY KEY, |  | ||||||
|                     newsletter_id INTEGER REFERENCES newsletters(id), |  | ||||||
|                     email TEXT NOT NULL, |  | ||||||
|                     status TEXT CHECK (status IN ('sent', 'failed', 'bounced')), |  | ||||||
|                     sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, |  | ||||||
|                     error_message TEXT |  | ||||||
|                 ) |  | ||||||
|             """ |             """ | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         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 | ||||||
|             ) |             ) | ||||||
|  |             """ | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|             conn.commit() |         conn.commit() | ||||||
|             logger.info("Basic database tables created successfully") |     except Exception: | ||||||
|              |         if conn: | ||||||
|             # Apply migrations to upgrade schema |             conn.rollback() | ||||||
|             migrate_database(conn) |  | ||||||
|              |  | ||||||
|             # Add indexes after migrations are complete |  | ||||||
|             try: |  | ||||||
|                 cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email)") |  | ||||||
|                 cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status)") |  | ||||||
|                 cursor.execute("CREATE INDEX IF NOT EXISTS idx_newsletters_sent_at ON newsletters(sent_at)") |  | ||||||
|                 conn.commit() |  | ||||||
|                 logger.info("Database indexes created successfully") |  | ||||||
|             except Exception as e: |  | ||||||
|                 logger.warning(f"Some indexes may not have been created: {e}") |  | ||||||
|                 # Continue anyway as this is not critical |  | ||||||
|              |  | ||||||
|             logger.info("Database initialization completed successfully") |  | ||||||
|              |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Database initialization error: {e}") |  | ||||||
|         raise |         raise | ||||||
|  |     finally: | ||||||
|  |         if cursor: | ||||||
|  |             cursor.close() | ||||||
|  |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
| def get_subscriber_stats(): | 
 | ||||||
|     """Get comprehensive subscriber statistics.""" | def get_all_emails(): | ||||||
|  |     """Return a list of all subscriber emails.""" | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         with get_db_connection() as conn: |         conn = get_connection() | ||||||
|             cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|  |         cursor.execute("SELECT email FROM subscribers") | ||||||
|  |         results = cursor.fetchall() | ||||||
|  |         return [row[0] for row in results] | ||||||
|  |     except Exception: | ||||||
|  |         return [] | ||||||
|  |     finally: | ||||||
|  |         if cursor: | ||||||
|  |             cursor.close() | ||||||
|  |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
|             # Check if status column exists |  | ||||||
|             has_status = check_column_exists(cursor, 'subscribers', 'status') |  | ||||||
| 
 | 
 | ||||||
|             if has_status: | def add_email(email): | ||||||
|                 # Get total subscribers with status filtering |  | ||||||
|                 cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'active'") |  | ||||||
|                 total_active = cursor.fetchone()[0] |  | ||||||
|                  |  | ||||||
|                 cursor.execute("SELECT COUNT(*) FROM subscribers WHERE status = 'unsubscribed'") |  | ||||||
|                 total_unsubscribed = cursor.fetchone()[0] |  | ||||||
|                  |  | ||||||
|                 # Get recent signups (last 30 days) - check if subscribed_at exists |  | ||||||
|                 has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at') |  | ||||||
|                 if has_subscribed_at: |  | ||||||
|                     cursor.execute(""" |  | ||||||
|                         SELECT COUNT(*) FROM subscribers  |  | ||||||
|                         WHERE subscribed_at >= NOW() - INTERVAL '30 days' AND status = 'active' |  | ||||||
|                     """) |  | ||||||
|                     recent_signups = cursor.fetchone()[0] |  | ||||||
|                 else: |  | ||||||
|                     recent_signups = 0 |  | ||||||
|             else: |  | ||||||
|                 # Fallback for old schema |  | ||||||
|                 cursor.execute("SELECT COUNT(*) FROM subscribers") |  | ||||||
|                 total_active = cursor.fetchone()[0] |  | ||||||
|                 total_unsubscribed = 0 |  | ||||||
|                 recent_signups = 0 |  | ||||||
|              |  | ||||||
|             # Get newsletters sent |  | ||||||
|             cursor.execute("SELECT COUNT(*) FROM newsletters") |  | ||||||
|             newsletters_sent = cursor.fetchone()[0] |  | ||||||
|              |  | ||||||
|             return { |  | ||||||
|                 'total_active': total_active, |  | ||||||
|                 'total_unsubscribed': total_unsubscribed, |  | ||||||
|                 'recent_signups': recent_signups, |  | ||||||
|                 'newsletters_sent': newsletters_sent |  | ||||||
|             } |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error retrieving subscriber stats: {e}") |  | ||||||
|         return {'total_active': 0, 'total_unsubscribed': 0, 'recent_signups': 0, 'newsletters_sent': 0} |  | ||||||
| 
 |  | ||||||
| def get_all_emails(page=1, per_page=50, search=''): |  | ||||||
|     """Return paginated list of subscriber emails with search functionality.""" |  | ||||||
|     try: |  | ||||||
|         with get_db_connection() as conn: |  | ||||||
|             cursor = conn.cursor() |  | ||||||
|              |  | ||||||
|             # Check which columns exist |  | ||||||
|             has_status = check_column_exists(cursor, 'subscribers', 'status') |  | ||||||
|             has_subscribed_at = check_column_exists(cursor, 'subscribers', 'subscribed_at') |  | ||||||
|             has_source = check_column_exists(cursor, 'subscribers', 'source') |  | ||||||
|              |  | ||||||
|             # Calculate offset |  | ||||||
|             offset = (page - 1) * per_page |  | ||||||
|              |  | ||||||
|             # Build base query based on available columns |  | ||||||
|             if has_status: |  | ||||||
|                 base_query = "FROM subscribers WHERE status = 'active'" |  | ||||||
|             else: |  | ||||||
|                 base_query = "FROM subscribers WHERE 1=1" |  | ||||||
|              |  | ||||||
|             params = [] |  | ||||||
|              |  | ||||||
|             if search: |  | ||||||
|                 base_query += " AND email ILIKE %s" |  | ||||||
|                 params.append(f"%{search}%") |  | ||||||
|              |  | ||||||
|             # Get total count |  | ||||||
|             cursor.execute(f"SELECT COUNT(*) {base_query}", params) |  | ||||||
|             total_count = cursor.fetchone()[0] |  | ||||||
|              |  | ||||||
|             # Build select query based on available columns |  | ||||||
|             select_fields = ["id", "email"] |  | ||||||
|             if has_subscribed_at: |  | ||||||
|                 select_fields.append("subscribed_at") |  | ||||||
|             if has_source: |  | ||||||
|                 select_fields.append("source") |  | ||||||
|              |  | ||||||
|             # Get paginated results |  | ||||||
|             query = f""" |  | ||||||
|                 SELECT {', '.join(select_fields)} |  | ||||||
|                 {base_query}  |  | ||||||
|                 ORDER BY {"subscribed_at DESC" if has_subscribed_at else "id DESC"} |  | ||||||
|                 LIMIT %s OFFSET %s |  | ||||||
|             """ |  | ||||||
|             params.extend([per_page, offset]) |  | ||||||
|             cursor.execute(query, params) |  | ||||||
|              |  | ||||||
|             results = cursor.fetchall() |  | ||||||
|             subscribers = [] |  | ||||||
|              |  | ||||||
|             for row in results: |  | ||||||
|                 subscriber = { |  | ||||||
|                     'id': row[0], |  | ||||||
|                     'email': row[1], |  | ||||||
|                     'subscribed_at': row[2] if has_subscribed_at and len(row) > 2 else None, |  | ||||||
|                     'source': row[3] if has_source and len(row) > 3 else 'manual' |  | ||||||
|                 } |  | ||||||
|                 subscribers.append(subscriber) |  | ||||||
|              |  | ||||||
|             return { |  | ||||||
|                 'subscribers': subscribers, |  | ||||||
|                 'total_count': total_count, |  | ||||||
|                 'page': page, |  | ||||||
|                 'per_page': per_page, |  | ||||||
|                 'total_pages': (total_count + per_page - 1) // per_page |  | ||||||
|             } |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error retrieving emails: {e}") |  | ||||||
|         return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0} |  | ||||||
| 
 |  | ||||||
| def add_email(email, source='manual'): |  | ||||||
|     """Insert an email into the subscribers table.""" |     """Insert an email into the subscribers table.""" | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         with get_db_connection() as conn: |         conn = get_connection() | ||||||
|             cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|              |         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) | ||||||
|             # Check if source column exists |         conn.commit() | ||||||
|             has_source = check_column_exists(cursor, 'subscribers', 'source') |         return True | ||||||
|              |  | ||||||
|             if has_source: |  | ||||||
|                 cursor.execute( |  | ||||||
|                     "INSERT INTO subscribers (email, source) VALUES (%s, %s)",  |  | ||||||
|                     (email.lower().strip(), source) |  | ||||||
|                 ) |  | ||||||
|             else: |  | ||||||
|                 cursor.execute( |  | ||||||
|                     "INSERT INTO subscribers (email) VALUES (%s)",  |  | ||||||
|                     (email.lower().strip(),) |  | ||||||
|                 ) |  | ||||||
|                  |  | ||||||
|             conn.commit() |  | ||||||
|             logger.info(f"Email {email} added successfully.") |  | ||||||
|             return True |  | ||||||
|     except IntegrityError: |     except IntegrityError: | ||||||
|         logger.warning(f"Attempted to add duplicate email: {email}") |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|         return False |         return False | ||||||
|     except Exception as e: |     except Exception: | ||||||
|         logger.error(f"Error adding email {email}: {e}") |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|         return False |         return False | ||||||
|  |     finally: | ||||||
|  |         if cursor: | ||||||
|  |             cursor.close() | ||||||
|  |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def remove_email(email): | def remove_email(email): | ||||||
|     """Mark email as unsubscribed or delete if status column doesn't exist.""" |     """Remove an email from the subscribers table.""" | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         with get_db_connection() as conn: |         conn = get_connection() | ||||||
|             cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|              |         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) | ||||||
|             # Check if status column exists |         rowcount = cursor.rowcount | ||||||
|             has_status = check_column_exists(cursor, 'subscribers', 'status') |         conn.commit() | ||||||
|              |         return rowcount > 0 | ||||||
|             if has_status: |     except Exception: | ||||||
|                 # Mark as unsubscribed |         if conn: | ||||||
|                 cursor.execute( |             conn.rollback() | ||||||
|                     "UPDATE subscribers SET status = 'unsubscribed' WHERE email = %s",  |  | ||||||
|                     (email,) |  | ||||||
|                 ) |  | ||||||
|             else: |  | ||||||
|                 # Delete the record (old behavior) |  | ||||||
|                 cursor.execute( |  | ||||||
|                     "DELETE FROM subscribers WHERE email = %s",  |  | ||||||
|                     (email,) |  | ||||||
|                 ) |  | ||||||
|                  |  | ||||||
|             rowcount = cursor.rowcount |  | ||||||
|             conn.commit() |  | ||||||
|             logger.info(f"Email {email} {'unsubscribed' if has_status else 'removed'} successfully.") |  | ||||||
|             return rowcount > 0 |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error {'unsubscribing' if has_status else 'removing'} email {email}: {e}") |  | ||||||
|         return False |         return False | ||||||
|  |     finally: | ||||||
|  |         if cursor: | ||||||
|  |             cursor.close() | ||||||
|  |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def get_admin(username): | def get_admin(username): | ||||||
|     """Retrieve admin credentials and update last login.""" |     """Retrieve admin credentials for a given username. | ||||||
|  |     Returns a tuple (username, password_hash) if found, otherwise None. | ||||||
|  |     """ | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         with get_db_connection() as conn: |         conn = get_connection() | ||||||
|             cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|             cursor.execute( |         cursor.execute( | ||||||
|                 """SELECT id, username, password, is_active  |             "SELECT username, password FROM admin_users WHERE username = %s", | ||||||
|                    FROM admin_users  |             (username,), | ||||||
|                    WHERE username = %s AND is_active = TRUE""", |         ) | ||||||
|                 (username,), |         return cursor.fetchone() | ||||||
|             ) |     except Exception: | ||||||
|             result = cursor.fetchone() |  | ||||||
|              |  | ||||||
|             if result: |  | ||||||
|                 # Update last login |  | ||||||
|                 cursor.execute( |  | ||||||
|                     "UPDATE admin_users SET last_login = %s WHERE id = %s", |  | ||||||
|                     (datetime.now(timezone.utc), result[0]) |  | ||||||
|                 ) |  | ||||||
|                 conn.commit() |  | ||||||
|                  |  | ||||||
|             return result |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error retrieving admin: {e}") |  | ||||||
|         return None |         return None | ||||||
|  |     finally: | ||||||
|  |         if cursor: | ||||||
|  |             cursor.close() | ||||||
|  |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def create_default_admin(): | def create_default_admin(): | ||||||
|     """Create a default admin user if one doesn't already exist.""" |     """Create a default admin user if one doesn't already exist.""" | ||||||
|  | @ -429,94 +176,59 @@ def create_default_admin(): | ||||||
|     default_password = os.getenv("ADMIN_PASSWORD", "changeme") |     default_password = os.getenv("ADMIN_PASSWORD", "changeme") | ||||||
|     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") |     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") | ||||||
| 
 | 
 | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         with get_db_connection() as conn: |         conn = get_connection() | ||||||
|             cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
| 
 | 
 | ||||||
|             # Check if any admin exists |         cursor.execute( | ||||||
|             cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE") |             "SELECT id FROM admin_users WHERE username = %s", (default_username,) | ||||||
|             admin_count = cursor.fetchone()[0] |         ) | ||||||
|  |         exists = cursor.fetchone() | ||||||
|  |         if exists is None: | ||||||
|  |             cursor.execute( | ||||||
|  |                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||||
|  |                 (default_username, hashed_password), | ||||||
|  |             ) | ||||||
|  |             conn.commit() | ||||||
|  |     except Exception: | ||||||
|  |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|  |     finally: | ||||||
|  |         if cursor: | ||||||
|  |             cursor.close() | ||||||
|  |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
|             if admin_count == 0: | 
 | ||||||
|                 cursor.execute( | def close_pool(): | ||||||
|                     "INSERT INTO admin_users (username, password) VALUES (%s, %s)", |     """Close the database connection pool.""" | ||||||
|                     (default_username, hashed_password), |     try: | ||||||
|                 ) |         conn_pool.closeall() | ||||||
|                 conn.commit() |     except Exception: | ||||||
|                 logger.info("Default admin created successfully") |         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: |             else: | ||||||
|                 logger.info("Admin users already exist") |                 self.conn.commit() | ||||||
|     except Exception as e: |         finally: | ||||||
|         logger.error(f"Error creating default admin: {e}") |             if self.cursor: | ||||||
| 
 |                 self.cursor.close() | ||||||
| def save_newsletter(subject, body, sent_by, recipient_count=0): |             if self.conn: | ||||||
|     """Save newsletter to database and return the ID.""" |                 conn_pool.putconn(self.conn) | ||||||
|     try: |  | ||||||
|         with get_db_connection() as conn: |  | ||||||
|             cursor = conn.cursor() |  | ||||||
|             cursor.execute( |  | ||||||
|                 """INSERT INTO newsletters (subject, body, sent_by, recipient_count)  |  | ||||||
|                    VALUES (%s, %s, %s, %s) RETURNING id""", |  | ||||||
|                 (subject, body, sent_by, recipient_count) |  | ||||||
|             ) |  | ||||||
|             newsletter_id = cursor.fetchone()[0] |  | ||||||
|             conn.commit() |  | ||||||
|             return newsletter_id |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error saving newsletter: {e}") |  | ||||||
|         return None |  | ||||||
| 
 |  | ||||||
| def update_newsletter_stats(newsletter_id, success_count, failure_count): |  | ||||||
|     """Update newsletter delivery statistics.""" |  | ||||||
|     try: |  | ||||||
|         with get_db_connection() as conn: |  | ||||||
|             cursor = conn.cursor() |  | ||||||
|             cursor.execute( |  | ||||||
|                 """UPDATE newsletters  |  | ||||||
|                    SET success_count = %s, failure_count = %s  |  | ||||||
|                    WHERE id = %s""", |  | ||||||
|                 (success_count, failure_count, newsletter_id) |  | ||||||
|             ) |  | ||||||
|             conn.commit() |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error updating newsletter stats: {e}") |  | ||||||
| 
 |  | ||||||
| def log_email_delivery(newsletter_id, email, status, error_message=None): |  | ||||||
|     """Log individual email delivery attempt.""" |  | ||||||
|     try: |  | ||||||
|         with get_db_connection() as conn: |  | ||||||
|             cursor = conn.cursor() |  | ||||||
|             cursor.execute( |  | ||||||
|                 """INSERT INTO email_deliveries (newsletter_id, email, status, error_message)  |  | ||||||
|                    VALUES (%s, %s, %s, %s)""", |  | ||||||
|                 (newsletter_id, email, status, error_message) |  | ||||||
|             ) |  | ||||||
|             conn.commit() |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error logging email delivery: {e}") |  | ||||||
| 
 |  | ||||||
| def get_recent_newsletters(limit=10): |  | ||||||
|     """Get recent newsletters with statistics.""" |  | ||||||
|     try: |  | ||||||
|         with get_db_connection() as conn: |  | ||||||
|             cursor = conn.cursor() |  | ||||||
|             cursor.execute( |  | ||||||
|                 """SELECT id, subject, sent_at, sent_by, recipient_count, success_count, failure_count |  | ||||||
|                    FROM newsletters  |  | ||||||
|                    ORDER BY sent_at DESC  |  | ||||||
|                    LIMIT %s""", |  | ||||||
|                 (limit,) |  | ||||||
|             ) |  | ||||||
|             results = cursor.fetchall() |  | ||||||
|             return [{ |  | ||||||
|                 'id': row[0], |  | ||||||
|                 'subject': row[1], |  | ||||||
|                 'sent_at': row[2], |  | ||||||
|                 'sent_by': row[3], |  | ||||||
|                 'recipient_count': row[4], |  | ||||||
|                 'success_count': row[5], |  | ||||||
|                 'failure_count': row[6] |  | ||||||
|             } for row in results] |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error retrieving recent newsletters: {e}") |  | ||||||
|         return [] |  | ||||||
|  | @ -3,3 +3,6 @@ flask | ||||||
| python-dotenv | python-dotenv | ||||||
| Werkzeug | Werkzeug | ||||||
| psycopg2-binary | psycopg2-binary | ||||||
|  | psycopg2-pool | ||||||
|  | python-decouple | ||||||
|  | markupsafe | ||||||
|  | @ -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 { | body { | ||||||
|     font-family: Arial, sans-serif; |   font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | ||||||
|     padding: 20px; |   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; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   table { | .navbar { | ||||||
|     border-collapse: collapse; |   position: sticky; | ||||||
|     width: 100%; |   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); | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   th, | .container { | ||||||
|   td { |   max-width: 1100px; | ||||||
|     border: 1px solid #ddd; |   margin: 28px auto 48px; | ||||||
|     padding: 8px; |   padding: 0 20px; | ||||||
|     text-align: left; |   padding-bottom: 64px; | ||||||
|   } | } | ||||||
| 
 | 
 | ||||||
|   th { | .footer { | ||||||
|     background-color: #f2f2f2; |   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); | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   a { | .footer-inner { | ||||||
|     margin-right: 10px; |   max-width: 1100px; | ||||||
|   } |   margin: 0 auto; | ||||||
|  |   padding: 14px 20px; | ||||||
|  |   color: var(--muted); | ||||||
|  |   font-size: 14px; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   form { | .page-header { | ||||||
|     max-width: 600px; |   margin-bottom: 18px; | ||||||
|     margin: 0 auto; |   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; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   label { | .card { | ||||||
|     display: block; |   background: var(--surface); | ||||||
|     margin-top: 15px; |   border: 1px solid var(--border); | ||||||
|   } |   border-radius: var(--radius); | ||||||
|  |   box-shadow: var(--shadow-1); | ||||||
|  |   padding: 20px; | ||||||
|  | } | ||||||
|  | .empty-state { | ||||||
|  |   text-align: center; | ||||||
|  |   color: var(--muted); | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   input[type="text"], | .widgets { | ||||||
|   input[type="password"], |   display: grid; | ||||||
|   textarea { |   grid-template-columns: repeat(3, minmax(0, 1fr)); | ||||||
|     width: 100%; |   gap: 16px; | ||||||
|     padding: 8px; |   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); | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   button { | .table-wrap { | ||||||
|     margin-top: 15px; |   overflow: hidden; | ||||||
|     padding: 10px 20px; |   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; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   .flash { | .form { | ||||||
|     background-color: #f8d7da; |   display: grid; | ||||||
|     color: #721c24; |   gap: 16px; | ||||||
|     padding: 10px; | } | ||||||
|     margin-bottom: 10px; | .form-group { | ||||||
|     text-align: center; |   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; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -1,868 +1,53 @@ | ||||||
| <!DOCTYPE html> | {% extends "base.html" %} | ||||||
| <html lang="en"> | {% block title %}Dashboard{% endblock %} | ||||||
| <head> | {% block content %} | ||||||
|     <meta charset="UTF-8"> |   <div class="page-header"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <div> | ||||||
|     <title>Newsletter Admin Dashboard</title> |       <h1 class="page-title">Dashboard</h1> | ||||||
|     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> |       <p class="page-subtitle">Quick overview of your mailing activity</p> | ||||||
|     <style> |  | ||||||
|         * { |  | ||||||
|             margin: 0; |  | ||||||
|             padding: 0; |  | ||||||
|             box-sizing: border-box; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         :root { |  | ||||||
|             --primary: #667eea; |  | ||||||
|             --primary-dark: #5a67d8; |  | ||||||
|             --secondary: #764ba2; |  | ||||||
|             --success: #48bb78; |  | ||||||
|             --warning: #ed8936; |  | ||||||
|             --error: #f56565; |  | ||||||
|             --info: #4299e1; |  | ||||||
|             --dark: #2d3748; |  | ||||||
|             --light: #f7fafc; |  | ||||||
|             --gray-100: #f7fafc; |  | ||||||
|             --gray-200: #edf2f7; |  | ||||||
|             --gray-300: #e2e8f0; |  | ||||||
|             --gray-400: #cbd5e0; |  | ||||||
|             --gray-500: #a0aec0; |  | ||||||
|             --gray-600: #718096; |  | ||||||
|             --gray-700: #4a5568; |  | ||||||
|             --gray-800: #2d3748; |  | ||||||
|             --gray-900: #1a202c; |  | ||||||
|             --white: #ffffff; |  | ||||||
|             --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); |  | ||||||
|             --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); |  | ||||||
|             --border-radius: 8px; |  | ||||||
|             --border-radius-lg: 12px; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         body { |  | ||||||
|             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |  | ||||||
|             background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-200) 100%); |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             line-height: 1.6; |  | ||||||
|             min-height: 100vh; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .container { |  | ||||||
|             max-width: 1200px; |  | ||||||
|             margin: 0 auto; |  | ||||||
|             padding: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header { |  | ||||||
|             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); |  | ||||||
|             color: white; |  | ||||||
|             padding: 2rem 0; |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|             border-radius: var(--border-radius-lg); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header-content { |  | ||||||
|             max-width: 1200px; |  | ||||||
|             margin: 0 auto; |  | ||||||
|             padding: 0 2rem; |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             align-items: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header h1 { |  | ||||||
|             font-size: 2rem; |  | ||||||
|             font-weight: 600; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.75rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .nav-links { |  | ||||||
|             display: flex; |  | ||||||
|             gap: 1.5rem; |  | ||||||
|             align-items: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .nav-links a { |  | ||||||
|             color: white; |  | ||||||
|             text-decoration: none; |  | ||||||
|             padding: 0.5rem 1rem; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .nav-links a:hover { |  | ||||||
|             background: rgba(255, 255, 255, 0.2); |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stats-grid { |  | ||||||
|             display: grid; |  | ||||||
|             grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |  | ||||||
|             gap: 1.5rem; |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stat-card { |  | ||||||
|             background: var(--white); |  | ||||||
|             padding: 2rem; |  | ||||||
|             border-radius: var(--border-radius-lg); |  | ||||||
|             box-shadow: var(--shadow); |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             border-left: 4px solid var(--primary); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stat-card:hover { |  | ||||||
|             transform: translateY(-4px); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stat-card.success { border-left-color: var(--success); } |  | ||||||
|         .stat-card.warning { border-left-color: var(--warning); } |  | ||||||
|         .stat-card.info { border-left-color: var(--info); } |  | ||||||
|         .stat-card.error { border-left-color: var(--error); } |  | ||||||
| 
 |  | ||||||
|         .stat-header { |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             align-items: center; |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stat-icon { |  | ||||||
|             width: 3rem; |  | ||||||
|             height: 3rem; |  | ||||||
|             border-radius: 50%; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             font-size: 1.25rem; |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stat-icon.primary { background: var(--primary); } |  | ||||||
|         .stat-icon.success { background: var(--success); } |  | ||||||
|         .stat-icon.warning { background: var(--warning); } |  | ||||||
|         .stat-icon.info { background: var(--info); } |  | ||||||
| 
 |  | ||||||
|         .stat-number { |  | ||||||
|             font-size: 2.5rem; |  | ||||||
|             font-weight: 700; |  | ||||||
|             color: var(--gray-800); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stat-label { |  | ||||||
|             color: var(--gray-600); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             text-transform: uppercase; |  | ||||||
|             letter-spacing: 0.05em; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .main-content { |  | ||||||
|             display: grid; |  | ||||||
|             grid-template-columns: 2fr 1fr; |  | ||||||
|             gap: 2rem; |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card { |  | ||||||
|             background: var(--white); |  | ||||||
|             border-radius: var(--border-radius-lg); |  | ||||||
|             box-shadow: var(--shadow); |  | ||||||
|             overflow: hidden; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card-header { |  | ||||||
|             padding: 1.5rem; |  | ||||||
|             border-bottom: 1px solid var(--gray-200); |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: between; |  | ||||||
|             align-items: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card-title { |  | ||||||
|             font-size: 1.25rem; |  | ||||||
|             font-weight: 600; |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card-body { |  | ||||||
|             padding: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .search-box { |  | ||||||
|             position: relative; |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .search-input { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 0.75rem 1rem 0.75rem 2.5rem; |  | ||||||
|             border: 2px solid var(--gray-300); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .search-input:focus { |  | ||||||
|             outline: none; |  | ||||||
|             border-color: var(--primary); |  | ||||||
|             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .search-icon { |  | ||||||
|             position: absolute; |  | ||||||
|             left: 0.75rem; |  | ||||||
|             top: 50%; |  | ||||||
|             transform: translateY(-50%); |  | ||||||
|             color: var(--gray-400); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .table-container { |  | ||||||
|             overflow-x: auto; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .table { |  | ||||||
|             width: 100%; |  | ||||||
|             border-collapse: collapse; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .table th, |  | ||||||
|         .table td { |  | ||||||
|             padding: 1rem; |  | ||||||
|             text-align: left; |  | ||||||
|             border-bottom: 1px solid var(--gray-200); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .table th { |  | ||||||
|             background: var(--gray-50); |  | ||||||
|             font-weight: 600; |  | ||||||
|             color: var(--gray-700); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|             text-transform: uppercase; |  | ||||||
|             letter-spacing: 0.05em; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .table tbody tr:hover { |  | ||||||
|             background: var(--gray-50); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn { |  | ||||||
|             display: inline-flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|             padding: 0.75rem 1.5rem; |  | ||||||
|             border: none; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             text-decoration: none; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             text-align: center; |  | ||||||
|             justify-content: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-primary { |  | ||||||
|             background: var(--primary); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-primary:hover { |  | ||||||
|             background: var(--primary-dark); |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-success { |  | ||||||
|             background: var(--success); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-warning { |  | ||||||
|             background: var(--warning); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-error { |  | ||||||
|             background: var(--error); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-small { |  | ||||||
|             padding: 0.5rem 1rem; |  | ||||||
|             font-size: 0.75rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash-messages { |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash { |  | ||||||
|             padding: 1rem; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.success { |  | ||||||
|             background: #f0fff4; |  | ||||||
|             color: #22543d; |  | ||||||
|             border: 1px solid #c6f6d5; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.error { |  | ||||||
|             background: #fff5f5; |  | ||||||
|             color: #742a2a; |  | ||||||
|             border: 1px solid #fed7d7; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.warning { |  | ||||||
|             background: #fffbeb; |  | ||||||
|             color: #744210; |  | ||||||
|             border: 1px solid #feebc8; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.info { |  | ||||||
|             background: #ebf8ff; |  | ||||||
|             color: #2a4a5a; |  | ||||||
|             border: 1px solid #bee3f8; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .recent-newsletters { |  | ||||||
|             max-height: 400px; |  | ||||||
|             overflow-y: auto; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .newsletter-item { |  | ||||||
|             padding: 1rem; |  | ||||||
|             border-bottom: 1px solid var(--gray-200); |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .newsletter-item:hover { |  | ||||||
|             background: var(--gray-50); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .newsletter-item:last-child { |  | ||||||
|             border-bottom: none; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .newsletter-subject { |  | ||||||
|             font-weight: 600; |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             margin-bottom: 0.25rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .newsletter-meta { |  | ||||||
|             font-size: 0.75rem; |  | ||||||
|             color: var(--gray-500); |  | ||||||
|             display: flex; |  | ||||||
|             gap: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .pagination { |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|             margin-top: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .pagination a, |  | ||||||
|         .pagination span { |  | ||||||
|             padding: 0.5rem 0.75rem; |  | ||||||
|             border: 1px solid var(--gray-300); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             color: var(--gray-600); |  | ||||||
|             text-decoration: none; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .pagination a:hover { |  | ||||||
|             background: var(--primary); |  | ||||||
|             color: white; |  | ||||||
|             border-color: var(--primary); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .pagination .current { |  | ||||||
|             background: var(--primary); |  | ||||||
|             color: white; |  | ||||||
|             border-color: var(--primary); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .add-subscriber { |  | ||||||
|             display: flex; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .add-subscriber input { |  | ||||||
|             flex: 1; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             border: 2px solid var(--gray-300); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .add-subscriber input:focus { |  | ||||||
|             outline: none; |  | ||||||
|             border-color: var(--primary); |  | ||||||
|             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @media (max-width: 768px) { |  | ||||||
|             .container { |  | ||||||
|                 padding: 1rem; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .header-content { |  | ||||||
|                 flex-direction: column; |  | ||||||
|                 gap: 1rem; |  | ||||||
|                 text-align: center; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .nav-links { |  | ||||||
|                 flex-wrap: wrap; |  | ||||||
|                 justify-content: center; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .main-content { |  | ||||||
|                 grid-template-columns: 1fr; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .stats-grid { |  | ||||||
|                 grid-template-columns: 1fr; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .loading { |  | ||||||
|             display: none; |  | ||||||
|             text-align: center; |  | ||||||
|             padding: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .spinner { |  | ||||||
|             display: inline-block; |  | ||||||
|             width: 2rem; |  | ||||||
|             height: 2rem; |  | ||||||
|             border: 3px solid var(--gray-300); |  | ||||||
|             border-top: 3px solid var(--primary); |  | ||||||
|             border-radius: 50%; |  | ||||||
|             animation: spin 1s linear infinite; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @keyframes spin { |  | ||||||
|             0% { transform: rotate(0deg); } |  | ||||||
|             100% { transform: rotate(360deg); } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .badge { |  | ||||||
|             display: inline-flex; |  | ||||||
|             align-items: center; |  | ||||||
|             padding: 0.25rem 0.75rem; |  | ||||||
|             border-radius: 9999px; |  | ||||||
|             font-size: 0.75rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .badge.success { |  | ||||||
|             background: #f0fff4; |  | ||||||
|             color: #22543d; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .badge.error { |  | ||||||
|             background: #fff5f5; |  | ||||||
|             color: #742a2a; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .empty-state { |  | ||||||
|             text-align: center; |  | ||||||
|             padding: 3rem; |  | ||||||
|             color: var(--gray-500); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .empty-state i { |  | ||||||
|             font-size: 3rem; |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|             color: var(--gray-400); |  | ||||||
|         } |  | ||||||
|     </style> |  | ||||||
| </head> |  | ||||||
| <body> |  | ||||||
|     <header class="header"> |  | ||||||
|         <div class="header-content"> |  | ||||||
|             <h1> |  | ||||||
|                 <i class="fas fa-envelope"></i> |  | ||||||
|                 Newsletter Admin |  | ||||||
|             </h1> |  | ||||||
|             <nav class="nav-links"> |  | ||||||
|                 <a href="/"><i class="fas fa-tachometer-alt"></i> Dashboard</a> |  | ||||||
|                 <a href="/subscribers"><i class="fas fa-users"></i> Subscribers</a> |  | ||||||
|                 <a href="/send_newsletter"><i class="fas fa-paper-plane"></i> Send Newsletter</a> |  | ||||||
|                 <a href="/newsletter_history"><i class="fas fa-history"></i> History</a> |  | ||||||
|                 <a href="/logout"><i class="fas fa-sign-out-alt"></i> Logout</a> |  | ||||||
|             </nav> |  | ||||||
|         </div> |  | ||||||
|     </header> |  | ||||||
| 
 |  | ||||||
|     <div class="container"> |  | ||||||
|         <!-- Flash Messages --> |  | ||||||
|         {% with messages = get_flashed_messages(with_categories=true) %} |  | ||||||
|         {% if messages %} |  | ||||||
|         <div class="flash-messages"> |  | ||||||
|             {% for category, message in messages %} |  | ||||||
|             <div class="flash {{ category }}"> |  | ||||||
|                 {% if category == 'success' %} |  | ||||||
|                 <i class="fas fa-check-circle"></i> |  | ||||||
|                 {% elif category == 'error' %} |  | ||||||
|                 <i class="fas fa-exclamation-circle"></i> |  | ||||||
|                 {% elif category == 'warning' %} |  | ||||||
|                 <i class="fas fa-exclamation-triangle"></i> |  | ||||||
|                 {% else %} |  | ||||||
|                 <i class="fas fa-info-circle"></i> |  | ||||||
|                 {% endif %} |  | ||||||
|                 {{ message }} |  | ||||||
|             </div> |  | ||||||
|             {% endfor %} |  | ||||||
|         </div> |  | ||||||
|         {% endif %} |  | ||||||
|         {% endwith %} |  | ||||||
| 
 |  | ||||||
|         <!-- Statistics Cards --> |  | ||||||
|         <div class="stats-grid"> |  | ||||||
|             <div class="stat-card success"> |  | ||||||
|                 <div class="stat-header"> |  | ||||||
|                     <div> |  | ||||||
|                         <div class="stat-number">{{ stats.total_active }}</div> |  | ||||||
|                         <div class="stat-label">Active Subscribers</div> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="stat-icon success"> |  | ||||||
|                         <i class="fas fa-users"></i> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <div class="stat-card info"> |  | ||||||
|                 <div class="stat-header"> |  | ||||||
|                     <div> |  | ||||||
|                         <div class="stat-number">{{ stats.recent_signups }}</div> |  | ||||||
|                         <div class="stat-label">New This Month</div> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="stat-icon info"> |  | ||||||
|                         <i class="fas fa-user-plus"></i> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <div class="stat-card primary"> |  | ||||||
|                 <div class="stat-header"> |  | ||||||
|                     <div> |  | ||||||
|                         <div class="stat-number">{{ stats.newsletters_sent }}</div> |  | ||||||
|                         <div class="stat-label">Newsletters Sent</div> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="stat-icon primary"> |  | ||||||
|                         <i class="fas fa-paper-plane"></i> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <div class="stat-card warning"> |  | ||||||
|                 <div class="stat-header"> |  | ||||||
|                     <div> |  | ||||||
|                         <div class="stat-number">{{ stats.total_unsubscribed }}</div> |  | ||||||
|                         <div class="stat-label">Unsubscribed</div> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="stat-icon warning"> |  | ||||||
|                         <i class="fas fa-user-minus"></i> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <!-- Main Content --> |  | ||||||
|         <div class="main-content"> |  | ||||||
|             <!-- Recent Subscribers --> |  | ||||||
|             <div class="card"> |  | ||||||
|                 <div class="card-header"> |  | ||||||
|                     <h2 class="card-title"> |  | ||||||
|                         <i class="fas fa-users"></i> |  | ||||||
|                         Recent Subscribers |  | ||||||
|                     </h2> |  | ||||||
|                     <a href="/subscribers" class="btn btn-primary btn-small"> |  | ||||||
|                         <i class="fas fa-eye"></i> View All |  | ||||||
|                     </a> |  | ||||||
|                 </div> |  | ||||||
|                 <div class="card-body"> |  | ||||||
|                     <!-- Add Subscriber Form --> |  | ||||||
|                     <div class="add-subscriber"> |  | ||||||
|                         <input type="email" id="newEmail" placeholder="Add new subscriber email..." /> |  | ||||||
|                         <button onclick="addSubscriber()" class="btn btn-success"> |  | ||||||
|                             <i class="fas fa-plus"></i> Add |  | ||||||
|                         </button> |  | ||||||
|                     </div> |  | ||||||
| 
 |  | ||||||
|                     <!-- Search Box --> |  | ||||||
|                     <div class="search-box"> |  | ||||||
|                         <i class="fas fa-search search-icon"></i> |  | ||||||
|                         <input type="text" class="search-input" placeholder="Search subscribers..."  |  | ||||||
|                                value="{{ search }}" onkeyup="filterSubscribers(this.value)"> |  | ||||||
|                     </div> |  | ||||||
| 
 |  | ||||||
|                     {% if subscribers %} |  | ||||||
|                     <div class="table-container"> |  | ||||||
|                         <table class="table"> |  | ||||||
|                             <thead> |  | ||||||
|                                 <tr> |  | ||||||
|                                     <th>Email Address</th> |  | ||||||
|                                     <th>Joined</th> |  | ||||||
|                                     <th>Source</th> |  | ||||||
|                                     <th>Actions</th> |  | ||||||
|                                 </tr> |  | ||||||
|                             </thead> |  | ||||||
|                             <tbody> |  | ||||||
|                                 {% for subscriber in subscribers %} |  | ||||||
|                                 <tr> |  | ||||||
|                                     <td>{{ subscriber.email }}</td> |  | ||||||
|                                     <td>{{ subscriber.subscribed_at.strftime('%b %d, %Y') if subscriber.subscribed_at else 'N/A' }}</td> |  | ||||||
|                                     <td> |  | ||||||
|                                         <span class="badge success">{{ subscriber.source or 'manual' }}</span> |  | ||||||
|                                     </td> |  | ||||||
|                                     <td> |  | ||||||
|                                         <button onclick="removeSubscriber('{{ subscriber.email }}')"  |  | ||||||
|                                                 class="btn btn-error btn-small"> |  | ||||||
|                                             <i class="fas fa-trash"></i> |  | ||||||
|                                         </button> |  | ||||||
|                                     </td> |  | ||||||
|                                 </tr> |  | ||||||
|                                 {% endfor %} |  | ||||||
|                             </tbody> |  | ||||||
|                         </table> |  | ||||||
|                     </div> |  | ||||||
| 
 |  | ||||||
|                     <!-- Pagination --> |  | ||||||
|                     {% if pagination.total_pages > 1 %} |  | ||||||
|                     <div class="pagination"> |  | ||||||
|                         {% if pagination.page > 1 %} |  | ||||||
|                         <a href="?page={{ pagination.page - 1 }}{% if search %}&search={{ search }}{% endif %}"> |  | ||||||
|                             <i class="fas fa-chevron-left"></i> Previous |  | ||||||
|                         </a> |  | ||||||
|                         {% endif %} |  | ||||||
| 
 |  | ||||||
|                         {% for page_num in range(1, pagination.total_pages + 1) %} |  | ||||||
|                         {% if page_num == pagination.page %} |  | ||||||
|                         <span class="current">{{ page_num }}</span> |  | ||||||
|                         {% else %} |  | ||||||
|                         <a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}">{{ page_num }}</a> |  | ||||||
|                         {% endif %} |  | ||||||
|                         {% endfor %} |  | ||||||
| 
 |  | ||||||
|                         {% if pagination.page < pagination.total_pages %} |  | ||||||
|                         <a href="?page={{ pagination.page + 1 }}{% if search %}&search={{ search }}{% endif %}"> |  | ||||||
|                             Next <i class="fas fa-chevron-right"></i> |  | ||||||
|                         </a> |  | ||||||
|                         {% endif %} |  | ||||||
|                     </div> |  | ||||||
|                     {% endif %} |  | ||||||
|                     {% else %} |  | ||||||
|                     <div class="empty-state"> |  | ||||||
|                         <i class="fas fa-users"></i> |  | ||||||
|                         <h3>No subscribers yet</h3> |  | ||||||
|                         <p>Start building your subscriber list!</p> |  | ||||||
|                     </div> |  | ||||||
|                     {% endif %} |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <!-- Recent Newsletters --> |  | ||||||
|             <div class="card"> |  | ||||||
|                 <div class="card-header"> |  | ||||||
|                     <h2 class="card-title"> |  | ||||||
|                         <i class="fas fa-history"></i> |  | ||||||
|                         Recent Newsletters |  | ||||||
|                     </h2> |  | ||||||
|                     <a href="/send_newsletter" class="btn btn-primary btn-small"> |  | ||||||
|                         <i class="fas fa-plus"></i> New |  | ||||||
|                     </a> |  | ||||||
|                 </div> |  | ||||||
|                 <div class="card-body"> |  | ||||||
|                     {% if recent_newsletters %} |  | ||||||
|                     <div class="recent-newsletters"> |  | ||||||
|                         {% for newsletter in recent_newsletters %} |  | ||||||
|                         <div class="newsletter-item"> |  | ||||||
|                             <div class="newsletter-subject">{{ newsletter.subject }}</div> |  | ||||||
|                             <div class="newsletter-meta"> |  | ||||||
|                                 <span><i class="fas fa-calendar"></i> {{ newsletter.sent_at.strftime('%b %d, %Y at %H:%M') if newsletter.sent_at else 'N/A' }}</span> |  | ||||||
|                                 <span><i class="fas fa-user"></i> {{ newsletter.sent_by or 'System' }}</span> |  | ||||||
|                                 {% if newsletter.success_count is not none %} |  | ||||||
|                                 <span><i class="fas fa-check"></i> {{ newsletter.success_count }} sent</span> |  | ||||||
|                                 {% endif %} |  | ||||||
|                                 {% if newsletter.failure_count and newsletter.failure_count > 0 %} |  | ||||||
|                                 <span><i class="fas fa-times"></i> {{ newsletter.failure_count }} failed</span> |  | ||||||
|                                 {% endif %} |  | ||||||
|                             </div> |  | ||||||
|                         </div> |  | ||||||
|                         {% endfor %} |  | ||||||
|                     </div> |  | ||||||
|                     {% else %} |  | ||||||
|                     <div class="empty-state"> |  | ||||||
|                         <i class="fas fa-paper-plane"></i> |  | ||||||
|                         <h3>No newsletters sent yet</h3> |  | ||||||
|                         <p>Send your first newsletter to get started!</p> |  | ||||||
|                         <a href="/send_newsletter" class="btn btn-primary"> |  | ||||||
|                             <i class="fas fa-plus"></i> Create Newsletter |  | ||||||
|                         </a> |  | ||||||
|                     </div> |  | ||||||
|                     {% endif %} |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |     </div> | ||||||
|  |     <div class="page-actions"> | ||||||
|  |       <a href="{{ url_for('send_update') }}" class="button button-primary">Send Update</a> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| 
 | 
 | ||||||
|     <script> |   <section class="widgets"> | ||||||
|         async function addSubscriber() { |     <div class="widget-card"> | ||||||
|             const emailInput = document.getElementById('newEmail'); |       <div class="widget-label">Total Subscribers</div> | ||||||
|             const email = emailInput.value.trim(); |       <div class="widget-value">{{ counts.total_subscribers }}</div> | ||||||
|  |     </div> | ||||||
|  |     <div class="widget-card"> | ||||||
|  |       <div class="widget-label">Newsletters Sent</div> | ||||||
|  |       <div class="widget-value">{{ counts.total_newsletters }}</div> | ||||||
|  |     </div> | ||||||
|  |     <div class="widget-card"> | ||||||
|  |       <div class="widget-label">Sent Today</div> | ||||||
|  |       <div class="widget-value">{{ counts.sent_today }}</div> | ||||||
|  |     </div> | ||||||
|  |   </section> | ||||||
| 
 | 
 | ||||||
|             if (!email) { |   {% if emails %} | ||||||
|                 alert('Please enter an email address'); |     <div class="card"> | ||||||
|                 return; |       <div class="table-wrap"> | ||||||
|             } |         <table class="table"> | ||||||
| 
 |           <thead> | ||||||
|             if (!isValidEmail(email)) { |             <tr> | ||||||
|                 alert('Please enter a valid email address'); |               <th>Email Address</th> | ||||||
|                 return; |             </tr> | ||||||
|             } |           </thead> | ||||||
| 
 |           <tbody> | ||||||
|             try { |             {% for email in emails %} | ||||||
|                 const response = await fetch('/add_subscriber', { |               <tr> | ||||||
|                     method: 'POST', |                 <td>{{ email }}</td> | ||||||
|                     headers: { |               </tr> | ||||||
|                         'Content-Type': 'application/json', |             {% endfor %} | ||||||
|                     }, |           </tbody> | ||||||
|                     body: JSON.stringify({ email: email }) |         </table> | ||||||
|                 }); |       </div> | ||||||
| 
 |     </div> | ||||||
|                 const result = await response.json(); |   {% else %} | ||||||
| 
 |     <div class="card empty-state"> | ||||||
|                 if (result.success) { |       <p>No subscribers found.</p> | ||||||
|                     showFlash('success', result.message); |     </div> | ||||||
|                     emailInput.value = ''; |   {% endif %} | ||||||
|                     setTimeout(() => location.reload(), 1000); | {% endblock %} | ||||||
|                 } else { |  | ||||||
|                     showFlash('error', result.message); |  | ||||||
|                 } |  | ||||||
|             } catch (error) { |  | ||||||
|                 showFlash('error', 'An error occurred while adding the subscriber'); |  | ||||||
|                 console.error('Error:', error); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         async function removeSubscriber(email) { |  | ||||||
|             if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) { |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             try { |  | ||||||
|                 const response = await fetch('/remove_subscriber', { |  | ||||||
|                     method: 'POST', |  | ||||||
|                     headers: { |  | ||||||
|                         'Content-Type': 'application/json', |  | ||||||
|                     }, |  | ||||||
|                     body: JSON.stringify({ email: email }) |  | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|                 const result = await response.json(); |  | ||||||
| 
 |  | ||||||
|                 if (result.success) { |  | ||||||
|                     showFlash('success', result.message); |  | ||||||
|                     setTimeout(() => location.reload(), 1000); |  | ||||||
|                 } else { |  | ||||||
|                     showFlash('error', result.message); |  | ||||||
|                 } |  | ||||||
|             } catch (error) { |  | ||||||
|                 showFlash('error', 'An error occurred while removing the subscriber'); |  | ||||||
|                 console.error('Error:', error); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function filterSubscribers(searchTerm) { |  | ||||||
|             if (searchTerm.length > 2 || searchTerm.length === 0) { |  | ||||||
|                 const url = new URL(window.location); |  | ||||||
|                 if (searchTerm) { |  | ||||||
|                     url.searchParams.set('search', searchTerm); |  | ||||||
|                 } else { |  | ||||||
|                     url.searchParams.delete('search'); |  | ||||||
|                 } |  | ||||||
|                 url.searchParams.set('page', '1'); |  | ||||||
|                  |  | ||||||
|                 // Debounce the search |  | ||||||
|                 clearTimeout(window.searchTimeout); |  | ||||||
|                 window.searchTimeout = setTimeout(() => { |  | ||||||
|                     window.location = url; |  | ||||||
|                 }, 500); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function isValidEmail(email) { |  | ||||||
|             const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |  | ||||||
|             return emailRegex.test(email); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function showFlash(type, message) { |  | ||||||
|             const flashContainer = document.querySelector('.flash-messages') || createFlashContainer(); |  | ||||||
|             const flash = document.createElement('div'); |  | ||||||
|             flash.className = `flash ${type}`; |  | ||||||
|              |  | ||||||
|             const icon = type === 'success' ? 'check-circle' :  |  | ||||||
|                         type === 'error' ? 'exclamation-circle' :  |  | ||||||
|                         type === 'warning' ? 'exclamation-triangle' : 'info-circle'; |  | ||||||
|              |  | ||||||
|             flash.innerHTML = `<i class="fas fa-${icon}"></i> ${message}`; |  | ||||||
|             flashContainer.appendChild(flash); |  | ||||||
| 
 |  | ||||||
|             setTimeout(() => { |  | ||||||
|                 flash.remove(); |  | ||||||
|             }, 5000); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function createFlashContainer() { |  | ||||||
|             const container = document.createElement('div'); |  | ||||||
|             container.className = 'flash-messages'; |  | ||||||
|             document.querySelector('.container').insertBefore(container, document.querySelector('.stats-grid')); |  | ||||||
|             return container; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Handle Enter key for add subscriber |  | ||||||
|         document.getElementById('newEmail').addEventListener('keypress', function(e) { |  | ||||||
|             if (e.key === 'Enter') { |  | ||||||
|                 addSubscriber(); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         // Auto-refresh stats every 30 seconds |  | ||||||
|         setInterval(async () => { |  | ||||||
|             try { |  | ||||||
|                 const response = await fetch('/api/stats'); |  | ||||||
|                 const stats = await response.json(); |  | ||||||
|                  |  | ||||||
|                 document.querySelector('.stat-card.success .stat-number').textContent = stats.total_active; |  | ||||||
|                 document.querySelector('.stat-card.info .stat-number').textContent = stats.recent_signups; |  | ||||||
|                 document.querySelector('.stat-card.primary .stat-number').textContent = stats.newsletters_sent; |  | ||||||
|                 document.querySelector('.stat-card.warning .stat-number').textContent = stats.total_unsubscribed; |  | ||||||
|             } catch (error) { |  | ||||||
|                 console.error('Error updating stats:', error); |  | ||||||
|             } |  | ||||||
|         }, 30000); |  | ||||||
|     </script> |  | ||||||
| </body> |  | ||||||
| </html> |  | ||||||
							
								
								
									
										58
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |   <meta charset="UTF-8" /> | ||||||
|  |   <meta | ||||||
|  |     name="viewport" | ||||||
|  |     content="width=device-width, initial-scale=1.0, viewport-fit=cover" | ||||||
|  |   /> | ||||||
|  |   <title>{% block title %}Admin{% endblock %}</title> | ||||||
|  |   <link | ||||||
|  |     rel="stylesheet" | ||||||
|  |     href="{{ url_for('static', filename='css/style.css') }}" | ||||||
|  |   /> | ||||||
|  |   <link | ||||||
|  |     rel="preconnect" | ||||||
|  |     href="https://fonts.googleapis.com" | ||||||
|  |     crossorigin | ||||||
|  |   /> | ||||||
|  |   <link | ||||||
|  |     href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" | ||||||
|  |     rel="stylesheet" | ||||||
|  |   /> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |   <header class="navbar"> | ||||||
|  |     <div class="navbar-content"> | ||||||
|  |       <a class="brand" href="{{ url_for('index') }}">Admin Panel</a> | ||||||
|  |       <nav class="navbar-links"> | ||||||
|  |         {% if session.get('username') %} | ||||||
|  |         <a href="{{ url_for('index') }}">Dashboard</a> | ||||||
|  |         <a href="{{ url_for('send_update') }}">Send Update</a> | ||||||
|  |         <a href="{{ url_for('logout') }}" class="logout">Logout</a> | ||||||
|  |         {% endif %} | ||||||
|  |       </nav> | ||||||
|  |     </div> | ||||||
|  |   </header> | ||||||
|  | 
 | ||||||
|  |   <main class="container"> | ||||||
|  |     {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|  |       {% if messages %} | ||||||
|  |         <div class="flash-stack"> | ||||||
|  |           {% for category, message in messages %} | ||||||
|  |             <div class="flash flash-{{ category|lower }}">{{ message }}</div> | ||||||
|  |           {% endfor %} | ||||||
|  |         </div> | ||||||
|  |       {% endif %} | ||||||
|  |     {% endwith %} | ||||||
|  | 
 | ||||||
|  |     {% block content %}{% endblock %} | ||||||
|  |   </main> | ||||||
|  | 
 | ||||||
|  |   <footer class="footer"> | ||||||
|  |     <div class="footer-inner"> | ||||||
|  |       <span>© {{ 2025 }} Admin Panel</span> | ||||||
|  |     </div> | ||||||
|  |   </footer> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
|  | @ -1,383 +1,34 @@ | ||||||
| <!DOCTYPE html> | {% extends "base.html" %} | ||||||
| <html lang="en"> | {% block title %}Admin Login{% endblock %} | ||||||
| <head> | {% block content %} | ||||||
|     <meta charset="UTF-8"> |   <section class="auth-wrapper"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <div class="card auth-card"> | ||||||
|     <title>Admin Login - Newsletter Admin</title> |       <h1 class="page-title">Welcome back</h1> | ||||||
|     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> |       <p class="page-subtitle">Sign in to manage your subscribers</p> | ||||||
|     <style> |  | ||||||
|         * { |  | ||||||
|             margin: 0; |  | ||||||
|             padding: 0; |  | ||||||
|             box-sizing: border-box; |  | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         :root { |       <form action="{{ url_for('login') }}" method="POST" class="form"> | ||||||
|             --primary: #667eea; |         <div class="form-group"> | ||||||
|             --primary-dark: #5a67d8; |           <label for="username">Username</label> | ||||||
|             --secondary: #764ba2; |           <input | ||||||
|             --success: #48bb78; |             type="text" | ||||||
|             --error: #f56565; |             name="username" | ||||||
|             --gray-100: #f7fafc; |             id="username" | ||||||
|             --gray-200: #edf2f7; |             autocomplete="username" | ||||||
|             --gray-300: #e2e8f0; |             required | ||||||
|             --gray-700: #4a5568; |           /> | ||||||
|             --gray-800: #2d3748; |  | ||||||
|             --white: #ffffff; |  | ||||||
|             --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1); |  | ||||||
|             --border-radius: 8px; |  | ||||||
|             --border-radius-lg: 12px; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         body { |  | ||||||
|             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |  | ||||||
|             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); |  | ||||||
|             min-height: 100vh; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             padding: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-container { |  | ||||||
|             background: var(--white); |  | ||||||
|             border-radius: var(--border-radius-lg); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|             padding: 3rem; |  | ||||||
|             width: 100%; |  | ||||||
|             max-width: 400px; |  | ||||||
|             text-align: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-header { |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-icon { |  | ||||||
|             width: 4rem; |  | ||||||
|             height: 4rem; |  | ||||||
|             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); |  | ||||||
|             border-radius: 50%; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             margin: 0 auto 1rem; |  | ||||||
|             color: white; |  | ||||||
|             font-size: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-title { |  | ||||||
|             font-size: 1.75rem; |  | ||||||
|             font-weight: 700; |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-subtitle { |  | ||||||
|             color: var(--gray-700); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-group { |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|             text-align: left; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-label { |  | ||||||
|             display: block; |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             color: var(--gray-700); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-input { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 0.75rem 1rem; |  | ||||||
|             border: 2px solid var(--gray-300); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             font-size: 1rem; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             background: var(--white); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-input:focus { |  | ||||||
|             outline: none; |  | ||||||
|             border-color: var(--primary); |  | ||||||
|             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .input-group { |  | ||||||
|             position: relative; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .input-icon { |  | ||||||
|             position: absolute; |  | ||||||
|             left: 1rem; |  | ||||||
|             top: 50%; |  | ||||||
|             transform: translateY(-50%); |  | ||||||
|             color: var(--gray-700); |  | ||||||
|             pointer-events: none; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .input-with-icon { |  | ||||||
|             padding-left: 2.75rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-btn { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 0.875rem; |  | ||||||
|             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); |  | ||||||
|             color: white; |  | ||||||
|             border: none; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             font-size: 1rem; |  | ||||||
|             font-weight: 600; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-btn:hover { |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|             box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-btn:active { |  | ||||||
|             transform: translateY(0); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .login-btn:disabled { |  | ||||||
|             opacity: 0.6; |  | ||||||
|             cursor: not-allowed; |  | ||||||
|             transform: none; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash-messages { |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash { |  | ||||||
|             padding: 1rem; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|             text-align: left; |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.success { |  | ||||||
|             background: #f0fff4; |  | ||||||
|             color: #22543d; |  | ||||||
|             border: 1px solid #c6f6d5; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.error { |  | ||||||
|             background: #fff5f5; |  | ||||||
|             color: #742a2a; |  | ||||||
|             border: 1px solid #fed7d7; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.warning { |  | ||||||
|             background: #fffbeb; |  | ||||||
|             color: #744210; |  | ||||||
|             border: 1px solid #feebc8; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.info { |  | ||||||
|             background: #ebf8ff; |  | ||||||
|             color: #2a4a5a; |  | ||||||
|             border: 1px solid #bee3f8; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .spinner { |  | ||||||
|             display: inline-block; |  | ||||||
|             width: 1rem; |  | ||||||
|             height: 1rem; |  | ||||||
|             border: 2px solid rgba(255, 255, 255, 0.3); |  | ||||||
|             border-top: 2px solid white; |  | ||||||
|             border-radius: 50%; |  | ||||||
|             animation: spin 1s linear infinite; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @keyframes spin { |  | ||||||
|             0% { transform: rotate(0deg); } |  | ||||||
|             100% { transform: rotate(360deg); } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .forgot-password { |  | ||||||
|             margin-top: 1.5rem; |  | ||||||
|             padding-top: 1.5rem; |  | ||||||
|             border-top: 1px solid var(--gray-200); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .forgot-password a { |  | ||||||
|             color: var(--primary); |  | ||||||
|             text-decoration: none; |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|             transition: color 0.3s ease; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .forgot-password a:hover { |  | ||||||
|             color: var(--primary-dark); |  | ||||||
|             text-decoration: underline; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @media (max-width: 480px) { |  | ||||||
|             .login-container { |  | ||||||
|                 padding: 2rem; |  | ||||||
|                 margin: 1rem; |  | ||||||
|             } |  | ||||||
|              |  | ||||||
|             .login-title { |  | ||||||
|                 font-size: 1.5rem; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .password-toggle { |  | ||||||
|             position: absolute; |  | ||||||
|             right: 1rem; |  | ||||||
|             top: 50%; |  | ||||||
|             transform: translateY(-50%); |  | ||||||
|             background: none; |  | ||||||
|             border: none; |  | ||||||
|             color: var(--gray-700); |  | ||||||
|             cursor: pointer; |  | ||||||
|             padding: 0; |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .password-toggle:hover { |  | ||||||
|             color: var(--primary); |  | ||||||
|         } |  | ||||||
|     </style> |  | ||||||
| </head> |  | ||||||
| <body> |  | ||||||
|     <div class="login-container"> |  | ||||||
|         <div class="login-header"> |  | ||||||
|             <div class="login-icon"> |  | ||||||
|                 <i class="fas fa-envelope"></i> |  | ||||||
|             </div> |  | ||||||
|             <h1 class="login-title">Newsletter Admin</h1> |  | ||||||
|             <p class="login-subtitle">Sign in to manage your newsletter</p> |  | ||||||
|         </div> |         </div> | ||||||
| 
 |         <div class="form-group"> | ||||||
|         <!-- Flash Messages --> |           <label for="password">Password</label> | ||||||
|         {% with messages = get_flashed_messages(with_categories=true) %} |           <input | ||||||
|         {% if messages %} |             type="password" | ||||||
|         <div class="flash-messages"> |             name="password" | ||||||
|             {% for category, message in messages %} |             id="password" | ||||||
|             <div class="flash {{ category }}"> |             autocomplete="current-password" | ||||||
|                 {% if category == 'success' %} |             required | ||||||
|                 <i class="fas fa-check-circle"></i> |           /> | ||||||
|                 {% elif category == 'error' %} |  | ||||||
|                 <i class="fas fa-exclamation-circle"></i> |  | ||||||
|                 {% elif category == 'warning' %} |  | ||||||
|                 <i class="fas fa-exclamation-triangle"></i> |  | ||||||
|                 {% else %} |  | ||||||
|                 <i class="fas fa-info-circle"></i> |  | ||||||
|                 {% endif %} |  | ||||||
|                 {{ message }} |  | ||||||
|             </div> |  | ||||||
|             {% endfor %} |  | ||||||
|         </div> |  | ||||||
|         {% endif %} |  | ||||||
|         {% endwith %} |  | ||||||
| 
 |  | ||||||
|         <form method="POST" onsubmit="handleLogin(event)"> |  | ||||||
|             <div class="form-group"> |  | ||||||
|                 <label for="username" class="form-label">Username</label> |  | ||||||
|                 <div class="input-group"> |  | ||||||
|                     <i class="fas fa-user input-icon"></i> |  | ||||||
|                     <input type="text"  |  | ||||||
|                            id="username"  |  | ||||||
|                            name="username"  |  | ||||||
|                            class="form-input input-with-icon"  |  | ||||||
|                            placeholder="Enter your username" |  | ||||||
|                            required  |  | ||||||
|                            autocomplete="username"> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <div class="form-group"> |  | ||||||
|                 <label for="password" class="form-label">Password</label> |  | ||||||
|                 <div class="input-group"> |  | ||||||
|                     <i class="fas fa-lock input-icon"></i> |  | ||||||
|                     <input type="password"  |  | ||||||
|                            id="password"  |  | ||||||
|                            name="password"  |  | ||||||
|                            class="form-input input-with-icon"  |  | ||||||
|                            placeholder="Enter your password" |  | ||||||
|                            required  |  | ||||||
|                            autocomplete="current-password"> |  | ||||||
|                     <button type="button" class="password-toggle" onclick="togglePassword()"> |  | ||||||
|                         <i class="fas fa-eye" id="password-icon"></i> |  | ||||||
|                     </button> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <button type="submit" class="login-btn" id="login-btn"> |  | ||||||
|                 <span id="login-text">Sign In</span> |  | ||||||
|                 <span id="login-spinner" class="spinner" style="display: none;"></span> |  | ||||||
|             </button> |  | ||||||
|         </form> |  | ||||||
| 
 |  | ||||||
|         <div class="forgot-password"> |  | ||||||
|             <p>Having trouble signing in? Contact your administrator.</p> |  | ||||||
|         </div> |         </div> | ||||||
|  |         <button type="submit" class="button button-primary">Login</button> | ||||||
|  |       </form> | ||||||
|     </div> |     </div> | ||||||
| 
 |   </section> | ||||||
|     <script> | {% endblock %} | ||||||
|         function togglePassword() { |  | ||||||
|             const passwordInput = document.getElementById('password'); |  | ||||||
|             const passwordIcon = document.getElementById('password-icon'); |  | ||||||
|              |  | ||||||
|             if (passwordInput.type === 'password') { |  | ||||||
|                 passwordInput.type = 'text'; |  | ||||||
|                 passwordIcon.className = 'fas fa-eye-slash'; |  | ||||||
|             } else { |  | ||||||
|                 passwordInput.type = 'password'; |  | ||||||
|                 passwordIcon.className = 'fas fa-eye'; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function handleLogin(event) { |  | ||||||
|             const loginBtn = document.getElementById('login-btn'); |  | ||||||
|             const loginText = document.getElementById('login-text'); |  | ||||||
|             const loginSpinner = document.getElementById('login-spinner'); |  | ||||||
|              |  | ||||||
|             // Show loading state |  | ||||||
|             loginBtn.disabled = true; |  | ||||||
|             loginText.style.display = 'none'; |  | ||||||
|             loginSpinner.style.display = 'inline-block'; |  | ||||||
|              |  | ||||||
|             // Reset form state after a short delay if needed |  | ||||||
|             setTimeout(() => { |  | ||||||
|                 if (loginBtn.disabled) { |  | ||||||
|                     loginBtn.disabled = false; |  | ||||||
|                     loginText.style.display = 'inline'; |  | ||||||
|                     loginSpinner.style.display = 'none'; |  | ||||||
|                 } |  | ||||||
|             }, 5000); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Auto-focus username field |  | ||||||
|         document.addEventListener('DOMContentLoaded', function() { |  | ||||||
|             document.getElementById('username').focus(); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         // Handle Enter key navigation |  | ||||||
|         document.getElementById('username').addEventListener('keypress', function(e) { |  | ||||||
|             if (e.key === 'Enter') { |  | ||||||
|                 e.preventDefault(); |  | ||||||
|                 document.getElementById('password').focus(); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     </script> |  | ||||||
| </body> |  | ||||||
| </html> |  | ||||||
|  | @ -1,667 +0,0 @@ | ||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="en"> |  | ||||||
| <head> |  | ||||||
|     <meta charset="UTF-8"> |  | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |  | ||||||
|     <title>Send Newsletter - Newsletter Admin</title> |  | ||||||
|     <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> |  | ||||||
|     <style> |  | ||||||
|         * { |  | ||||||
|             margin: 0; |  | ||||||
|             padding: 0; |  | ||||||
|             box-sizing: border-box; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         :root { |  | ||||||
|             --primary: #667eea; |  | ||||||
|             --primary-dark: #5a67d8; |  | ||||||
|             --secondary: #764ba2; |  | ||||||
|             --success: #48bb78; |  | ||||||
|             --warning: #ed8936; |  | ||||||
|             --error: #f56565; |  | ||||||
|             --info: #4299e1; |  | ||||||
|             --dark: #2d3748; |  | ||||||
|             --light: #f7fafc; |  | ||||||
|             --gray-100: #f7fafc; |  | ||||||
|             --gray-200: #edf2f7; |  | ||||||
|             --gray-300: #e2e8f0; |  | ||||||
|             --gray-400: #cbd5e0; |  | ||||||
|             --gray-500: #a0aec0; |  | ||||||
|             --gray-600: #718096; |  | ||||||
|             --gray-700: #4a5568; |  | ||||||
|             --gray-800: #2d3748; |  | ||||||
|             --gray-900: #1a202c; |  | ||||||
|             --white: #ffffff; |  | ||||||
|             --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); |  | ||||||
|             --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); |  | ||||||
|             --border-radius: 8px; |  | ||||||
|             --border-radius-lg: 12px; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         body { |  | ||||||
|             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |  | ||||||
|             background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-200) 100%); |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             line-height: 1.6; |  | ||||||
|             min-height: 100vh; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .container { |  | ||||||
|             max-width: 1200px; |  | ||||||
|             margin: 0 auto; |  | ||||||
|             padding: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header { |  | ||||||
|             background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); |  | ||||||
|             color: white; |  | ||||||
|             padding: 2rem 0; |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|             border-radius: var(--border-radius-lg); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header-content { |  | ||||||
|             max-width: 1200px; |  | ||||||
|             margin: 0 auto; |  | ||||||
|             padding: 0 2rem; |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             align-items: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .header h1 { |  | ||||||
|             font-size: 2rem; |  | ||||||
|             font-weight: 600; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.75rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .nav-links { |  | ||||||
|             display: flex; |  | ||||||
|             gap: 1.5rem; |  | ||||||
|             align-items: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .nav-links a { |  | ||||||
|             color: white; |  | ||||||
|             text-decoration: none; |  | ||||||
|             padding: 0.5rem 1rem; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .nav-links a:hover { |  | ||||||
|             background: rgba(255, 255, 255, 0.2); |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash-messages { |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash { |  | ||||||
|             padding: 1rem; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.success { |  | ||||||
|             background: #f0fff4; |  | ||||||
|             color: #22543d; |  | ||||||
|             border: 1px solid #c6f6d5; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.error { |  | ||||||
|             background: #fff5f5; |  | ||||||
|             color: #742a2a; |  | ||||||
|             border: 1px solid #fed7d7; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.warning { |  | ||||||
|             background: #fffbeb; |  | ||||||
|             color: #744210; |  | ||||||
|             border: 1px solid #feebc8; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .flash.info { |  | ||||||
|             background: #ebf8ff; |  | ||||||
|             color: #2a4a5a; |  | ||||||
|             border: 1px solid #bee3f8; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .main-content { |  | ||||||
|             display: grid; |  | ||||||
|             grid-template-columns: 2fr 1fr; |  | ||||||
|             gap: 2rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card { |  | ||||||
|             background: var(--white); |  | ||||||
|             border-radius: var(--border-radius-lg); |  | ||||||
|             box-shadow: var(--shadow); |  | ||||||
|             overflow: hidden; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card-header { |  | ||||||
|             padding: 1.5rem; |  | ||||||
|             border-bottom: 1px solid var(--gray-200); |  | ||||||
|             background: var(--gray-50); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card-title { |  | ||||||
|             font-size: 1.25rem; |  | ||||||
|             font-weight: 600; |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .card-body { |  | ||||||
|             padding: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-group { |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-label { |  | ||||||
|             display: block; |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             color: var(--gray-700); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-input { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             border: 2px solid var(--gray-300); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             font-size: 1rem; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-input:focus { |  | ||||||
|             outline: none; |  | ||||||
|             border-color: var(--primary); |  | ||||||
|             box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .form-textarea { |  | ||||||
|             min-height: 300px; |  | ||||||
|             resize: vertical; |  | ||||||
|             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn { |  | ||||||
|             display: inline-flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|             padding: 0.75rem 1.5rem; |  | ||||||
|             border: none; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             text-decoration: none; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             text-align: center; |  | ||||||
|             justify-content: center; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-primary { |  | ||||||
|             background: var(--primary); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-primary:hover { |  | ||||||
|             background: var(--primary-dark); |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-secondary { |  | ||||||
|             background: var(--gray-600); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-secondary:hover { |  | ||||||
|             background: var(--gray-700); |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-success { |  | ||||||
|             background: var(--success); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-success:hover { |  | ||||||
|             background: #38a169; |  | ||||||
|             transform: translateY(-2px); |  | ||||||
|             box-shadow: var(--shadow-lg); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn:disabled { |  | ||||||
|             opacity: 0.6; |  | ||||||
|             cursor: not-allowed; |  | ||||||
|             transform: none; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .btn-group { |  | ||||||
|             display: flex; |  | ||||||
|             gap: 1rem; |  | ||||||
|             justify-content: flex-end; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .preview-container { |  | ||||||
|             border: 2px dashed var(--gray-300); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             padding: 2rem; |  | ||||||
|             margin-top: 1rem; |  | ||||||
|             background: var(--gray-50); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .preview-container.has-content { |  | ||||||
|             border-color: var(--primary); |  | ||||||
|             background: var(--white); |  | ||||||
|             border-style: solid; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .email-preview { |  | ||||||
|             background: var(--white); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             padding: 1.5rem; |  | ||||||
|             box-shadow: var(--shadow); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .email-preview h3 { |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|             padding-bottom: 0.5rem; |  | ||||||
|             border-bottom: 2px solid var(--primary); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .email-body { |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|             line-height: 1.7; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .toolbar { |  | ||||||
|             display: flex; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             background: var(--gray-100); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             border: 1px solid var(--gray-300); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .toolbar button { |  | ||||||
|             padding: 0.5rem; |  | ||||||
|             border: none; |  | ||||||
|             background: var(--white); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: all 0.3s ease; |  | ||||||
|             color: var(--gray-600); |  | ||||||
|             min-width: 2.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .toolbar button:hover { |  | ||||||
|             background: var(--primary); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stats-info { |  | ||||||
|             background: var(--gray-100); |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             padding: 1rem; |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stats-info h3 { |  | ||||||
|             color: var(--gray-800); |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             font-size: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stats-item { |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             margin-bottom: 0.25rem; |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .stats-item strong { |  | ||||||
|             color: var(--primary); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .tips { |  | ||||||
|             background: var(--info); |  | ||||||
|             color: white; |  | ||||||
|             padding: 1rem; |  | ||||||
|             border-radius: var(--border-radius); |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .tips h3 { |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             gap: 0.5rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .tips ul { |  | ||||||
|             margin-left: 1rem; |  | ||||||
|             font-size: 0.875rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .tips li { |  | ||||||
|             margin-bottom: 0.25rem; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         .spinner { |  | ||||||
|             display: inline-block; |  | ||||||
|             width: 1rem; |  | ||||||
|             height: 1rem; |  | ||||||
|             border: 2px solid rgba(255, 255, 255, 0.3); |  | ||||||
|             border-top: 2px solid white; |  | ||||||
|             border-radius: 50%; |  | ||||||
|             animation: spin 1s linear infinite; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @keyframes spin { |  | ||||||
|             0% { transform: rotate(0deg); } |  | ||||||
|             100% { transform: rotate(360deg); } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         @media (max-width: 768px) { |  | ||||||
|             .container { |  | ||||||
|                 padding: 1rem; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .header-content { |  | ||||||
|                 flex-direction: column; |  | ||||||
|                 gap: 1rem; |  | ||||||
|                 text-align: center; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .nav-links { |  | ||||||
|                 flex-wrap: wrap; |  | ||||||
|                 justify-content: center; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .main-content { |  | ||||||
|                 grid-template-columns: 1fr; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             .btn-group { |  | ||||||
|                 flex-direction: column; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     </style> |  | ||||||
| </head> |  | ||||||
| <body> |  | ||||||
|     <header class="header"> |  | ||||||
|         <div class="header-content"> |  | ||||||
|             <h1> |  | ||||||
|                 <i class="fas fa-paper-plane"></i> |  | ||||||
|                 Send Newsletter |  | ||||||
|             </h1> |  | ||||||
|             <nav class="nav-links"> |  | ||||||
|                 <a href="/"><i class="fas fa-tachometer-alt"></i> Dashboard</a> |  | ||||||
|                 <a href="/subscribers"><i class="fas fa-users"></i> Subscribers</a> |  | ||||||
|                 <a href="/newsletter_history"><i class="fas fa-history"></i> History</a> |  | ||||||
|                 <a href="/logout"><i class="fas fa-sign-out-alt"></i> Logout</a> |  | ||||||
|             </nav> |  | ||||||
|         </div> |  | ||||||
|     </header> |  | ||||||
| 
 |  | ||||||
|     <div class="container"> |  | ||||||
|         <!-- Flash Messages --> |  | ||||||
|         {% with messages = get_flashed_messages(with_categories=true) %} |  | ||||||
|         {% if messages %} |  | ||||||
|         <div class="flash-messages"> |  | ||||||
|             {% for category, message in messages %} |  | ||||||
|             <div class="flash {{ category }}"> |  | ||||||
|                 {% if category == 'success' %} |  | ||||||
|                 <i class="fas fa-check-circle"></i> |  | ||||||
|                 {% elif category == 'error' %} |  | ||||||
|                 <i class="fas fa-exclamation-circle"></i> |  | ||||||
|                 {% elif category == 'warning' %} |  | ||||||
|                 <i class="fas fa-exclamation-triangle"></i> |  | ||||||
|                 {% else %} |  | ||||||
|                 <i class="fas fa-info-circle"></i> |  | ||||||
|                 {% endif %} |  | ||||||
|                 {{ message }} |  | ||||||
|             </div> |  | ||||||
|             {% endfor %} |  | ||||||
|         </div> |  | ||||||
|         {% endif %} |  | ||||||
|         {% endwith %} |  | ||||||
| 
 |  | ||||||
|         <div class="main-content"> |  | ||||||
|             <!-- Newsletter Form --> |  | ||||||
|             <div class="card"> |  | ||||||
|                 <div class="card-header"> |  | ||||||
|                     <h2 class="card-title"> |  | ||||||
|                         <i class="fas fa-edit"></i> |  | ||||||
|                         Compose Newsletter |  | ||||||
|                     </h2> |  | ||||||
|                 </div> |  | ||||||
|                 <div class="card-body"> |  | ||||||
|                     <form method="POST" id="newsletter-form"> |  | ||||||
|                         <div class="form-group"> |  | ||||||
|                             <label for="subject" class="form-label">Subject Line</label> |  | ||||||
|                             <input type="text"  |  | ||||||
|                                    id="subject"  |  | ||||||
|                                    name="subject"  |  | ||||||
|                                    class="form-input"  |  | ||||||
|                                    placeholder="Enter a compelling subject line..." |  | ||||||
|                                    value="{{ subject or '' }}" |  | ||||||
|                                    required> |  | ||||||
|                         </div> |  | ||||||
| 
 |  | ||||||
|                         <div class="form-group"> |  | ||||||
|                             <label for="body" class="form-label">Email Content (HTML allowed)</label> |  | ||||||
|                              |  | ||||||
|                             <!-- Rich Text Toolbar --> |  | ||||||
|                             <div class="toolbar"> |  | ||||||
|                                 <button type="button" onclick="formatText('bold')" title="Bold"> |  | ||||||
|                                     <i class="fas fa-bold"></i> |  | ||||||
|                                 </button> |  | ||||||
|                                 <button type="button" onclick="formatText('italic')" title="Italic"> |  | ||||||
|                                     <i class="fas fa-italic"></i> |  | ||||||
|                                 </button> |  | ||||||
|                                 <button type="button" onclick="formatText('underline')" title="Underline"> |  | ||||||
|                                     <i class="fas fa-underline"></i> |  | ||||||
|                                 </button> |  | ||||||
|                                 <button type="button" onclick="insertLink()" title="Insert Link"> |  | ||||||
|                                     <i class="fas fa-link"></i> |  | ||||||
|                                 </button> |  | ||||||
|                                 <button type="button" onclick="insertList()" title="Insert List"> |  | ||||||
|                                     <i class="fas fa-list-ul"></i> |  | ||||||
|                                 </button> |  | ||||||
|                             </div> |  | ||||||
| 
 |  | ||||||
|                             <textarea id="body"  |  | ||||||
|                                       name="body"  |  | ||||||
|                                       class="form-input form-textarea"  |  | ||||||
|                                       placeholder="Write your newsletter content here... HTML tags are supported." |  | ||||||
|                                       required>{{ body or '' }}</textarea> |  | ||||||
|                         </div> |  | ||||||
| 
 |  | ||||||
|                         <div class="btn-group"> |  | ||||||
|                             <button type="submit" name="action" value="preview" class="btn btn-secondary"> |  | ||||||
|                                 <i class="fas fa-eye"></i> Preview |  | ||||||
|                             </button> |  | ||||||
|                             <button type="submit" name="action" value="send" class="btn btn-success" id="send-btn"> |  | ||||||
|                                 <span id="send-text"> |  | ||||||
|                                     <i class="fas fa-paper-plane"></i> Send Newsletter |  | ||||||
|                                 </span> |  | ||||||
|                                 <span id="send-spinner" class="spinner" style="display: none;"></span> |  | ||||||
|                             </button> |  | ||||||
|                         </div> |  | ||||||
|                     </form> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <!-- Sidebar --> |  | ||||||
|             <div> |  | ||||||
|                 <!-- Statistics --> |  | ||||||
|                 <div class="card"> |  | ||||||
|                     <div class="card-header"> |  | ||||||
|                         <h3 class="card-title"> |  | ||||||
|                             <i class="fas fa-chart-bar"></i> |  | ||||||
|                             Quick Stats |  | ||||||
|                         </h3> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="card-body"> |  | ||||||
|                         <div class="stats-info" id="subscriber-stats"> |  | ||||||
|                             <h3>Subscriber Information</h3> |  | ||||||
|                             <div class="stats-item"> |  | ||||||
|                                 <span>Active Subscribers:</span> |  | ||||||
|                                 <strong id="active-count">Loading...</strong> |  | ||||||
|                             </div> |  | ||||||
|                             <div class="stats-item"> |  | ||||||
|                                 <span>New This Month:</span> |  | ||||||
|                                 <strong id="recent-count">Loading...</strong> |  | ||||||
|                             </div> |  | ||||||
|                             <div class="stats-item"> |  | ||||||
|                                 <span>Total Sent:</span> |  | ||||||
|                                 <strong id="sent-count">Loading...</strong> |  | ||||||
|                             </div> |  | ||||||
|                         </div> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
| 
 |  | ||||||
|                 <!-- Tips --> |  | ||||||
|                 <div class="tips"> |  | ||||||
|                     <h3><i class="fas fa-lightbulb"></i> Writing Tips</h3> |  | ||||||
|                     <ul> |  | ||||||
|                         <li>Keep subject lines under 50 characters</li> |  | ||||||
|                         <li>Use personalization when possible</li> |  | ||||||
|                         <li>Include a clear call-to-action</li> |  | ||||||
|                         <li>Test your content before sending</li> |  | ||||||
|                         <li>Mobile-friendly formatting is key</li> |  | ||||||
|                     </ul> |  | ||||||
|                 </div> |  | ||||||
| 
 |  | ||||||
|                 <!-- Preview Container --> |  | ||||||
|                 {% if preview %} |  | ||||||
|                 <div class="card"> |  | ||||||
|                     <div class="card-header"> |  | ||||||
|                         <h3 class="card-title"> |  | ||||||
|                             <i class="fas fa-eye"></i> |  | ||||||
|                             Email Preview |  | ||||||
|                         </h3> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="card-body"> |  | ||||||
|                         <div class="preview-container has-content"> |  | ||||||
|                             {{ preview|safe }} |  | ||||||
|                         </div> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| 
 |  | ||||||
|     <script> |  | ||||||
|         // Load subscriber stats |  | ||||||
|         async function loadStats() { |  | ||||||
|             try { |  | ||||||
|                 const response = await fetch('/api/stats'); |  | ||||||
|                 const stats = await response.json(); |  | ||||||
|                  |  | ||||||
|                 document.getElementById('active-count').textContent = stats.total_active; |  | ||||||
|                 document.getElementById('recent-count').textContent = stats.recent_signups; |  | ||||||
|                 document.getElementById('sent-count').textContent = stats.newsletters_sent; |  | ||||||
|             } catch (error) { |  | ||||||
|                 console.error('Error loading stats:', error); |  | ||||||
|                 document.getElementById('active-count').textContent = 'Error'; |  | ||||||
|                 document.getElementById('recent-count').textContent = 'Error'; |  | ||||||
|                 document.getElementById('sent-count').textContent = 'Error'; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Rich text formatting functions |  | ||||||
|         function formatText(command) { |  | ||||||
|             const textarea = document.getElementById('body'); |  | ||||||
|             const start = textarea.selectionStart; |  | ||||||
|             const end = textarea.selectionEnd; |  | ||||||
|             const selectedText = textarea.value.substring(start, end); |  | ||||||
|              |  | ||||||
|             if (selectedText) { |  | ||||||
|                 let formattedText = ''; |  | ||||||
|                 switch (command) { |  | ||||||
|                     case 'bold': |  | ||||||
|                         formattedText = `<strong>${selectedText}</strong>`; |  | ||||||
|                         break; |  | ||||||
|                     case 'italic': |  | ||||||
|                         formattedText = `<em>${selectedText}</em>`; |  | ||||||
|                         break; |  | ||||||
|                     case 'underline': |  | ||||||
|                         formattedText = `<u>${selectedText}</u>`; |  | ||||||
|                         break; |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 textarea.value = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end); |  | ||||||
|                 textarea.focus(); |  | ||||||
|                 textarea.setSelectionRange(start, start + formattedText.length); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function insertLink() { |  | ||||||
|             const url = prompt('Enter the URL:'); |  | ||||||
|             const text = prompt('Enter the link text:') || url; |  | ||||||
|              |  | ||||||
|             if (url) { |  | ||||||
|                 const textarea = document.getElementById('body'); |  | ||||||
|                 const start = textarea.selectionStart; |  | ||||||
|                 const linkText = `<a href="${url}">${text}</a>`; |  | ||||||
|                  |  | ||||||
|                 textarea.value = textarea.value.substring(0, start) + linkText + textarea.value.substring(textarea.selectionEnd); |  | ||||||
|                 textarea.focus(); |  | ||||||
|                 textarea.setSelectionRange(start + linkText.length, start + linkText.length); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         function insertList() { |  | ||||||
|             const textarea = document.getElementById('body'); |  | ||||||
|             const start = textarea.selectionStart; |  | ||||||
|             const listText = `<ul>\n  <li>List item 1</li>\n  <li>List item 2</li>\n  <li>List item 3</li>\n</ul>`; |  | ||||||
|              |  | ||||||
|             textarea.value = textarea.value.substring(0, start) + listText + textarea.value.substring(textarea.selectionEnd); |  | ||||||
|             textarea.focus(); |  | ||||||
|             textarea.setSelectionRange(start + listText.length, start + listText.length); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Handle form submission |  | ||||||
|         document.getElementById('newsletter-form').addEventListener('submit', function(e) { |  | ||||||
|             const action = e.submitter.value; |  | ||||||
|              |  | ||||||
|             if (action === 'send') { |  | ||||||
|                 if (!confirm('Are you sure you want to send this newsletter to all subscribers? This action cannot be undone.')) { |  | ||||||
|                     e.preventDefault(); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Show loading state |  | ||||||
|                 const sendBtn = document.getElementById('send-btn'); |  | ||||||
|                 const sendText = document.getElementById('send-text'); |  | ||||||
|                 const sendSpinner = document.getElementById('send-spinner'); |  | ||||||
|                  |  | ||||||
|                 sendBtn.disabled = true; |  | ||||||
|                 sendText.style.display = 'none'; |  | ||||||
|                 sendSpinner |  | ||||||
							
								
								
									
										32
									
								
								templates/send_update.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								templates/send_update.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | ||||||
|  | {% extends "base.html" %} | ||||||
|  | {% block title %}Send Update{% endblock %} | ||||||
|  | {% block content %} | ||||||
|  |   <div class="page-header"> | ||||||
|  |     <div> | ||||||
|  |       <h1 class="page-title">Send Update</h1> | ||||||
|  |       <p class="page-subtitle">Send an email update to all subscribers</p> | ||||||
|  |     </div> | ||||||
|  |     <div class="page-actions"> | ||||||
|  |       <a href="{{ url_for('index') }}" class="button button-secondary">Dashboard</a> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | 
 | ||||||
|  |   <div class="card"> | ||||||
|  |     <form action="{{ url_for('send_update') }}" method="POST" class="form"> | ||||||
|  |       <div class="form-group"> | ||||||
|  |         <label for="subject">Subject</label> | ||||||
|  |         <input type="text" name="subject" id="subject" required /> | ||||||
|  |       </div> | ||||||
|  | 
 | ||||||
|  |       <div class="form-group"> | ||||||
|  |         <label for="body">Body (HTML allowed)</label> | ||||||
|  |         <textarea name="body" id="body" rows="12" required | ||||||
|  |           placeholder="<h1>Title</h1><p>Your content...</p>"></textarea> | ||||||
|  |       </div> | ||||||
|  | 
 | ||||||
|  |       <div class="form-actions"> | ||||||
|  |         <button type="submit" class="button button-primary">Send Update</button> | ||||||
|  |       </div> | ||||||
|  |     </form> | ||||||
|  |   </div> | ||||||
|  | {% endblock %} | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue