refactor: init overhaul of admin panel
This commit is contained in:
		
							parent
							
								
									9d78f1fdb4
								
							
						
					
					
						commit
						69468dc5bf
					
				
					 6 changed files with 2686 additions and 295 deletions
				
			
		
							
								
								
									
										402
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										402
									
								
								app.py
									
										
									
									
									
								
							|  | @ -2,6 +2,9 @@ import os | |||
| import logging | ||||
| import smtplib | ||||
| from email.mime.text import MIMEText | ||||
| from email.mime.multipart import MIMEMultipart | ||||
| import concurrent.futures | ||||
| from threading import Lock | ||||
| from flask import ( | ||||
|     Flask, | ||||
|     render_template, | ||||
|  | @ -10,23 +13,35 @@ from flask import ( | |||
|     url_for, | ||||
|     flash, | ||||
|     session, | ||||
|     jsonify, | ||||
|     abort | ||||
| ) | ||||
| from dotenv import load_dotenv | ||||
| from werkzeug.security import check_password_hash | ||||
| from functools import wraps  # Import wraps | ||||
| from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin | ||||
| from functools import wraps | ||||
| import re | ||||
| from database import ( | ||||
|     get_db_connection, init_db, get_all_emails, get_admin, create_default_admin, | ||||
|     get_subscriber_stats, add_email, remove_email, save_newsletter, | ||||
|     update_newsletter_stats, log_email_delivery, get_recent_newsletters | ||||
| ) | ||||
| 
 | ||||
| load_dotenv() | ||||
| app = Flask(__name__) | ||||
| app.secret_key = os.getenv("SECRET_KEY") | ||||
| base_url = os.getenv("BASE_URL") | ||||
| 
 | ||||
| # SMTP settings (for sending update emails) | ||||
| # SMTP settings | ||||
| SMTP_SERVER = os.getenv("SMTP_SERVER") | ||||
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | ||||
| SMTP_USER = os.getenv("SMTP_USER") | ||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | ||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL | ||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) | ||||
| SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin") | ||||
| 
 | ||||
| # Email sending configuration | ||||
| MAX_EMAIL_WORKERS = 5 | ||||
| email_send_lock = Lock() | ||||
| 
 | ||||
| # Logging setup | ||||
| logging.basicConfig( | ||||
|  | @ -34,117 +49,366 @@ logging.basicConfig( | |||
| ) | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| # Initialize the database and create default admin user if necessary. | ||||
| # Initialize the database and create default admin user | ||||
| init_db() | ||||
| create_default_admin() | ||||
| 
 | ||||
| # Decorator for requiring login | ||||
| # Security decorators | ||||
| def login_required(f): | ||||
|     @wraps(f)  # Use wraps to preserve function metadata | ||||
|     @wraps(f) | ||||
|     def decorated_function(*args, **kwargs): | ||||
|         if "username" not in session: | ||||
|             if request.is_json: | ||||
|                 return jsonify({"error": "Authentication required"}), 401 | ||||
|             flash("Please log in to access this page.", "warning") | ||||
|             return redirect(url_for("login")) | ||||
|         return f(*args, **kwargs) | ||||
| 
 | ||||
|     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_update_email(subject, body, email): | ||||
|     """Sends email, returns True on success, False on failure.""" | ||||
| def send_single_email(email_data): | ||||
|     """Send a single email (for thread pool execution).""" | ||||
|     email, subject, body, newsletter_id = email_data | ||||
|     try: | ||||
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) | ||||
|         server.set_debuglevel(False)  # Keep debug level at False for production | ||||
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15) | ||||
|         server.login(SMTP_USER, SMTP_PASSWORD) | ||||
| 
 | ||||
|         # Create message | ||||
|         msg = MIMEMultipart('alternative') | ||||
|         msg['Subject'] = subject | ||||
|         msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>" | ||||
|         msg['To'] = email | ||||
| 
 | ||||
|         # Create unsubscribe link | ||||
|         unsub_link = f"https://{base_url}/unsubscribe?email={email}" | ||||
|         custom_body = ( | ||||
|             f"{body}<br><br>" | ||||
|             f"If you ever wish to unsubscribe, please click <a href='{unsub_link}'>here</a>" | ||||
|         ) | ||||
|          | ||||
|         msg = MIMEText(custom_body, "html", "utf-8") | ||||
|         msg["Subject"] = subject | ||||
|         msg["From"] = SENDER_EMAIL  # Use sender email | ||||
|         msg["To"] = email | ||||
|         # HTML body with unsubscribe link | ||||
|         html_body = f""" | ||||
|         <html> | ||||
|         <body> | ||||
|         {body} | ||||
|         <hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;"> | ||||
|         <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> | ||||
|         </p> | ||||
|         </body> | ||||
|         </html> | ||||
|         """ | ||||
| 
 | ||||
|         server.sendmail(SENDER_EMAIL, email, msg.as_string())  # Use sender email | ||||
|         # Attach HTML part | ||||
|         html_part = MIMEText(html_body, 'html') | ||||
|         msg.attach(html_part) | ||||
| 
 | ||||
|         server.sendmail(SENDER_EMAIL, email, msg.as_string()) | ||||
|         server.quit() | ||||
|         logger.info(f"Update email sent to: {email}") | ||||
|         return True | ||||
|          | ||||
|         # Log successful delivery | ||||
|         if newsletter_id: | ||||
|             log_email_delivery(newsletter_id, email, 'sent') | ||||
|          | ||||
|         logger.info(f"Email sent successfully to: {email}") | ||||
|         return True, email, None | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to send email to {email}: {e}") | ||||
|         return False | ||||
|         error_msg = str(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) | ||||
|          | ||||
| def process_send_update_email(subject, body): | ||||
|     """Helper function to send an update email to all subscribers.""" | ||||
|     subscribers = get_all_emails() | ||||
|     if not subscribers: | ||||
|         return "No subscribers found." | ||||
|     try: | ||||
|         for email in subscribers: | ||||
|             if not send_update_email(subject, body, email): | ||||
|                 return f"Failed to send to {email}"  # Specific failure message | ||||
|         return False, email, error_msg | ||||
| 
 | ||||
|         # Log newsletter content for audit purposes | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute( | ||||
|             "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) | ||||
|         ) | ||||
|         conn.commit() | ||||
|         cursor.close() | ||||
|         conn.close() | ||||
| 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 = [] | ||||
|      | ||||
|         return "Email has been sent to all subscribers." | ||||
|     except Exception as e: | ||||
|         logger.exception("Error processing sending updates") | ||||
|         return f"Failed to send email: {e}" | ||||
|     # 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 | ||||
| def index(): | ||||
|     """Displays all subscriber emails""" | ||||
|     emails = get_all_emails() | ||||
|     return render_template("admin_index.html", emails=emails) | ||||
|     """Dashboard with subscriber statistics and recent activity.""" | ||||
|     page = request.args.get('page', 1, type=int) | ||||
|     search = request.args.get('search', '') | ||||
|     per_page = 25 | ||||
|      | ||||
| @app.route("/send_update", methods=["GET", "POST"]) | ||||
|     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 send_update(): | ||||
|     """Display a form to send an update email; process submission on POST.""" | ||||
|     if request.method == "POST": | ||||
|         subject = request.form["subject"] | ||||
|         body = request.form["body"] | ||||
|         result_message = process_send_update_email(subject, body) | ||||
|         flash(result_message) | ||||
|         return redirect(url_for("send_update")) | ||||
|     return render_template("send_update.html") | ||||
| 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: | ||||
|         data = request.get_json() | ||||
|         email = data.get('email', '').strip().lower() | ||||
|          | ||||
|         if not email or not validate_email(email): | ||||
|             return jsonify({"success": False, "message": "Invalid email format"}), 400 | ||||
|          | ||||
|         success = add_email(email, source='admin_manual') | ||||
|          | ||||
|         if success: | ||||
|             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 | ||||
| def remove_subscriber(): | ||||
|     """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": | ||||
|         action = request.form.get('action', 'send') | ||||
|         subject = request.form.get('subject', '').strip() | ||||
|         body = request.form.get('body', '').strip() | ||||
|          | ||||
|         if not subject or not body: | ||||
|             flash("Subject and body are required", "error") | ||||
|             return redirect(url_for('send_newsletter')) | ||||
|          | ||||
|         if action == 'preview': | ||||
|             # Return preview | ||||
|             preview_html = f""" | ||||
|             <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': | ||||
|             # 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"]) | ||||
| def login(): | ||||
|     if request.method == "POST": | ||||
|         username = request.form.get("username") | ||||
|         password = request.form.get("password") | ||||
|         username = request.form.get("username", "").strip() | ||||
|         password = request.form.get("password", "") | ||||
|          | ||||
|         if not username or not password: | ||||
|             flash("Username and password are required", "error") | ||||
|             return redirect(url_for("login")) | ||||
|          | ||||
|         admin = get_admin(username) | ||||
|         if admin and check_password_hash(admin[1], password): | ||||
|         if admin and len(admin) >= 3 and check_password_hash(admin[2], 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 | ||||
|             flash("Logged in successfully", "success") | ||||
|             session["admin_id"] = admin[0] | ||||
|             flash("Logged in successfully!", "success") | ||||
|              | ||||
|             # Redirect to intended page or dashboard | ||||
|             next_page = request.args.get('next') | ||||
|             if next_page: | ||||
|                 return redirect(next_page) | ||||
|             return redirect(url_for("index")) | ||||
|         else: | ||||
|             flash("Invalid username or password", "danger") | ||||
|             flash("Invalid username or password", "error") | ||||
|             return redirect(url_for("login")) | ||||
|     return render_template("login.html") | ||||
|      | ||||
|     return render_template("login.html") | ||||
| 
 | ||||
| @app.route("/logout") | ||||
| def logout(): | ||||
|     session.pop("username", None) | ||||
|     flash("Logged out successfully", "success") | ||||
|     session.clear() | ||||
|     flash("You have been logged out successfully", "info") | ||||
|     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__": | ||||
|     app.run(port=5001, debug=True) | ||||
|     app.run(host="0.0.0.0", port=5001, debug=True) | ||||
							
								
								
									
										579
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										579
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,9 +1,11 @@ | |||
| import os | ||||
| import logging | ||||
| import psycopg2 | ||||
| from psycopg2 import IntegrityError | ||||
| from psycopg2 import IntegrityError, pool | ||||
| from dotenv import load_dotenv | ||||
| from werkzeug.security import generate_password_hash | ||||
| from contextlib import contextmanager | ||||
| from datetime import datetime, timezone | ||||
| 
 | ||||
| load_dotenv() | ||||
| 
 | ||||
|  | @ -13,191 +15,508 @@ logging.basicConfig( | |||
| ) | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| # Connection pool for better performance | ||||
| connection_pool = None | ||||
| 
 | ||||
| def get_connection(): | ||||
|     """Return a new connection to the PostgreSQL database.""" | ||||
| def init_connection_pool(): | ||||
|     """Initialize the connection pool.""" | ||||
|     global connection_pool | ||||
|     try: | ||||
|         conn = psycopg2.connect( | ||||
|         connection_pool = psycopg2.pool.ThreadedConnectionPool( | ||||
|             1, 20,  # min and max connections | ||||
|             host=os.getenv("PG_HOST"), | ||||
|             port=os.getenv("PG_PORT"), | ||||
|             dbname=os.getenv("PG_DATABASE"), | ||||
|             user=os.getenv("PG_USER"), | ||||
|             password=os.getenv("PG_PASSWORD"), | ||||
|             connect_timeout=10, | ||||
|         ) | ||||
|         return conn | ||||
|         logger.info("Connection pool created successfully") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Database connection error: {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] | ||||
| 
 | ||||
| def migrate_database(conn): | ||||
|     """Apply database migrations to upgrade existing schema.""" | ||||
|     cursor = conn.cursor() | ||||
|      | ||||
|     # Migration 1: Add new columns to subscribers table | ||||
|     if not check_column_exists(cursor, 'subscribers', 'subscribed_at'): | ||||
|         logger.info("Adding subscribed_at column to subscribers table") | ||||
|         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(): | ||||
|     """Initialize the database tables.""" | ||||
|     conn = None | ||||
|     """Initialize the database tables with improved schema.""" | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         with get_db_connection() as conn: | ||||
|             cursor = conn.cursor() | ||||
| 
 | ||||
|         # Create subscribers table (if not exists) | ||||
|         cursor.execute( | ||||
|             # Create basic subscribers table (backwards compatible) | ||||
|             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 admin_users table (if not exists) | ||||
|         cursor.execute( | ||||
|             # 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 TABLE IF NOT EXISTS admin_users ( | ||||
|                 id SERIAL PRIMARY KEY, | ||||
|                 username TEXT UNIQUE NOT NULL, | ||||
|                 password TEXT NOT NULL | ||||
|             ) | ||||
|         """ | ||||
|         ) | ||||
| 
 | ||||
|         # Newsletter storage | ||||
|         cursor.execute( | ||||
|             # Create basic newsletters table (backwards compatible) | ||||
|             cursor.execute( | ||||
|                 """ | ||||
|                 CREATE TABLE IF NOT EXISTS newsletters ( | ||||
|                     id SERIAL PRIMARY KEY, | ||||
|                     subject TEXT NOT NULL, | ||||
|                     body TEXT NOT NULL, | ||||
|                     sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP | ||||
|                 ) | ||||
|             """ | ||||
|         CREATE TABLE IF NOT EXISTS newsletters ( | ||||
|             id SERIAL PRIMARY KEY, | ||||
|             subject TEXT NOT NULL, | ||||
|             body TEXT NOT NULL, | ||||
|             sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP | ||||
|         ) | ||||
|         """ | ||||
|         ) | ||||
|             ) | ||||
| 
 | ||||
|             # 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 | ||||
|                 ) | ||||
|             """ | ||||
|             ) | ||||
| 
 | ||||
|             conn.commit() | ||||
|             logger.info("Basic database tables created successfully") | ||||
|              | ||||
|             # Apply migrations to upgrade schema | ||||
|             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") | ||||
|              | ||||
|         conn.commit() | ||||
|         logger.info("Database initialized successfully.") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Database initialization error: {e}") | ||||
|         if conn: | ||||
|             conn.rollback()  # Rollback if there's an error | ||||
| 
 | ||||
|         raise | ||||
|     finally: | ||||
|         if conn: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
| 
 | ||||
| 
 | ||||
| def get_all_emails(): | ||||
|     """Return a list of all subscriber emails.""" | ||||
| def get_subscriber_stats(): | ||||
|     """Get comprehensive subscriber statistics.""" | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("SELECT email FROM subscribers") | ||||
|         results = cursor.fetchall() | ||||
|         emails = [row[0] for row in results] | ||||
|         logger.debug(f"Retrieved emails: {emails}") | ||||
|         return emails | ||||
|         with get_db_connection() as conn: | ||||
|             cursor = conn.cursor() | ||||
|              | ||||
|             # Check if status column exists | ||||
|             has_status = check_column_exists(cursor, 'subscribers', 'status') | ||||
|              | ||||
|             if has_status: | ||||
|                 # 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 [] | ||||
|     finally: | ||||
|         if conn: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         return {'subscribers': [], 'total_count': 0, 'page': 1, 'per_page': per_page, 'total_pages': 0} | ||||
| 
 | ||||
| 
 | ||||
| def add_email(email): | ||||
| def add_email(email, source='manual'): | ||||
|     """Insert an email into the subscribers table.""" | ||||
|     conn = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) | ||||
|         conn.commit() | ||||
|         logger.info(f"Email {email} added successfully.") | ||||
|         return True | ||||
|         with get_db_connection() as conn: | ||||
|             cursor = conn.cursor() | ||||
|              | ||||
|             # Check if source column exists | ||||
|             has_source = check_column_exists(cursor, 'subscribers', 'source') | ||||
|              | ||||
|             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: | ||||
|         logger.warning(f"Attempted to add duplicate email: {email}") | ||||
|         return False | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error adding email {email}: {e}") | ||||
|         return False | ||||
|     finally: | ||||
|         if conn: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
| 
 | ||||
| 
 | ||||
| def remove_email(email): | ||||
|     """Remove an email from the subscribers table.""" | ||||
|     conn = None | ||||
|     """Mark email as unsubscribed or delete if status column doesn't exist.""" | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) | ||||
|         rowcount = cursor.rowcount | ||||
|         conn.commit() | ||||
|         logger.info(f"Email {email} removed successfully.") | ||||
|         return rowcount > 0 | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error removing email {email}: {e}") | ||||
|         return False | ||||
|     finally: | ||||
|         if conn: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         with get_db_connection() as conn: | ||||
|             cursor = conn.cursor() | ||||
|              | ||||
|             # Check if status column exists | ||||
|             has_status = check_column_exists(cursor, 'subscribers', 'status') | ||||
|              | ||||
|             if has_status: | ||||
|                 # Mark as unsubscribed | ||||
|                 cursor.execute( | ||||
|                     "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 | ||||
| 
 | ||||
| def get_admin(username): | ||||
|     """Retrieve admin credentials for a given username. | ||||
|     Returns a tuple (username, password_hash) if found, otherwise None. | ||||
|     """ | ||||
|     conn = None | ||||
|     """Retrieve admin credentials and update last login.""" | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute( | ||||
|             "SELECT username, password FROM admin_users WHERE username = %s", | ||||
|             (username,), | ||||
|         ) | ||||
|         result = cursor.fetchone() | ||||
|         return result  # (username, password_hash) | ||||
|         with get_db_connection() as conn: | ||||
|             cursor = conn.cursor() | ||||
|             cursor.execute( | ||||
|                 """SELECT id, username, password, is_active  | ||||
|                    FROM admin_users  | ||||
|                    WHERE username = %s AND is_active = TRUE""", | ||||
|                 (username,), | ||||
|             ) | ||||
|             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 | ||||
|     finally: | ||||
|         if conn: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
| 
 | ||||
| 
 | ||||
| def create_default_admin(): | ||||
|     """Create a default admin user if one doesn't already exist.""" | ||||
|     default_username = os.getenv("ADMIN_USERNAME", "admin") | ||||
|     default_password = os.getenv("ADMIN_PASSWORD", "changeme") | ||||
|     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") | ||||
|     conn = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|      | ||||
|         # Check if the admin already exists | ||||
|         cursor.execute( | ||||
|             "SELECT id FROM admin_users WHERE username = %s", (default_username,) | ||||
|         ) | ||||
|         if cursor.fetchone() is None: | ||||
|             cursor.execute( | ||||
|                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||
|                 (default_username, hashed_password), | ||||
|             ) | ||||
|             conn.commit() | ||||
|             logger.info("Default admin created successfully") | ||||
|         else: | ||||
|             logger.info("Default admin already exists") | ||||
|     try: | ||||
|         with get_db_connection() as conn: | ||||
|             cursor = conn.cursor() | ||||
| 
 | ||||
|             # Check if any admin exists | ||||
|             cursor.execute("SELECT COUNT(*) FROM admin_users WHERE is_active = TRUE") | ||||
|             admin_count = cursor.fetchone()[0] | ||||
|              | ||||
|             if admin_count == 0: | ||||
|                 cursor.execute( | ||||
|                     "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||
|                     (default_username, hashed_password), | ||||
|                 ) | ||||
|                 conn.commit() | ||||
|                 logger.info("Default admin created successfully") | ||||
|             else: | ||||
|                 logger.info("Admin users already exist") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error creating default admin: {e}") | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|     finally: | ||||
|         if conn: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
| 
 | ||||
| def save_newsletter(subject, body, sent_by, recipient_count=0): | ||||
|     """Save newsletter to database and return the ID.""" | ||||
|     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,42 +3,866 @@ | |||
| <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>Admin Center - Subscribers</title> | ||||
|     <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||
|     <title>Newsletter Admin Dashboard</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); | ||||
|         } | ||||
| 
 | ||||
|         .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> | ||||
| 
 | ||||
|     <h1>Subscribers</h1> | ||||
|     <p> | ||||
|       <a href="{{ url_for('send_update') }}">Send Update Email</a>| | ||||
|       <a href="{{ url_for('logout') }}">Logout</a> | ||||
|     </p> | ||||
|     <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 %} | ||||
| 
 | ||||
|     {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|       {% if messages %} | ||||
|         {% for category, message in messages %} | ||||
|           <div class="flash">{{ message }}</div> | ||||
|         {% endfor %} | ||||
|       {% 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> | ||||
| 
 | ||||
|     {% if emails %} | ||||
|         <table> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th>Email Address</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for email in emails %} | ||||
|                     <tr> | ||||
|                         <td>{{ email }}</td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|         </table> | ||||
|     {% else %} | ||||
|         <p>No subscribers found.</p> | ||||
|     {% endif %} | ||||
|             <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> | ||||
| 
 | ||||
|     <script> | ||||
|         async function addSubscriber() { | ||||
|             const emailInput = document.getElementById('newEmail'); | ||||
|             const email = emailInput.value.trim(); | ||||
| 
 | ||||
|             if (!email) { | ||||
|                 alert('Please enter an email address'); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (!isValidEmail(email)) { | ||||
|                 alert('Please enter a valid email address'); | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 const response = await fetch('/add_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); | ||||
|                     emailInput.value = ''; | ||||
|                     setTimeout(() => location.reload(), 1000); | ||||
|                 } 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> | ||||
|  | @ -1,29 +1,383 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| 
 | ||||
| <head> | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|   <title>Admin Login</title> | ||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>Admin Login - 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; | ||||
|             --error: #f56565; | ||||
|             --gray-100: #f7fafc; | ||||
|             --gray-200: #edf2f7; | ||||
|             --gray-300: #e2e8f0; | ||||
|             --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> | ||||
|   <h1>Admin Login</h1> | ||||
|   {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|   {% if messages %} | ||||
|   {% for category, message in messages %} | ||||
|   <div class="flash">{{ message }}</div> | ||||
|   {% endfor %} | ||||
|   {% endif %} | ||||
|   {% endwith %} | ||||
|   <form action="{{ url_for('login') }}" method="POST"> | ||||
|     <label for="username">Username:</label> | ||||
|     <input type="text" name="username" required /> | ||||
|     <label for="password">Password:</label> | ||||
|     <input type="password" name="password" required /> | ||||
|     <button type="submit">Login</button> | ||||
|   </form> | ||||
| </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> | ||||
| 
 | ||||
|         <!-- 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 %} | ||||
| 
 | ||||
|         <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> | ||||
| 
 | ||||
|     <script> | ||||
|         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> | ||||
							
								
								
									
										667
									
								
								templates/send_newsletter.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										667
									
								
								templates/send_newsletter.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,667 @@ | |||
| <!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 | ||||
|  | @ -1,37 +0,0 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| 
 | ||||
| <head> | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|   <title>Admin Center - Send Update</title> | ||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|   <h1>Send Update Email</h1> | ||||
|   <p> | ||||
|     <a href="{{ url_for('index') }}">Back to Subscribers List</a> | | ||||
|     <a href="{{ url_for('logout') }}">Logout</a> | ||||
|   </p> | ||||
|   {% with messages = get_flashed_messages() %} | ||||
|     {% if messages %} | ||||
|       {% for message in messages %} | ||||
|         <div class="flash">{{ message }}</div> | ||||
|       {% endfor %} | ||||
|     {% endif %} | ||||
|   {% endwith %} | ||||
| 
 | ||||
|   <form action="{{ url_for('send_update') }}" method="POST"> | ||||
|     <label for="subject">Subject:</label> | ||||
|     <input type="text" name="subject" required> | ||||
| 
 | ||||
|     <label for="body">Body (HTML allowed):</label> | ||||
|     <textarea name="body" rows="10" required></textarea> | ||||
| 
 | ||||
|     <button type="submit">Send Update</button> | ||||
|   </form> | ||||
| 
 | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Cipher Vance
						Cipher Vance