Added web front end

This commit is contained in:
James Pattinson
2025-10-12 20:55:13 +00:00
parent b8a91103e9
commit ba1bf32393
10 changed files with 2315 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ Mailing List Management API
FastAPI-based REST API for managing mailing lists and members FastAPI-based REST API for managing mailing lists and members
""" """
from fastapi import FastAPI, HTTPException, Depends, Header from fastapi import FastAPI, HTTPException, Depends, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import List, Optional from typing import List, Optional
@@ -26,6 +27,15 @@ app = FastAPI(
version="1.0.0" version="1.0.0"
) )
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify your frontend domain
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
security = HTTPBearer() security = HTTPBearer()
# Database connection # Database connection

View File

@@ -48,5 +48,15 @@ services:
networks: networks:
- maillist-internal - maillist-internal
web:
build: ./web
container_name: maillist-web
depends_on:
- api
ports:
- "3000:80"
networks:
- maillist-internal
volumes: volumes:
mysql_data: mysql_data:

14
web/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Use nginx to serve the static files
FROM nginx:alpine
# Copy the web files to nginx html directory
COPY . /usr/share/nginx/html/
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

127
web/README.md Normal file
View File

@@ -0,0 +1,127 @@
# Web Frontend for Mailing List Manager
This directory contains the web frontend for the Mailing List Manager - a clean, modern web interface for managing mailing lists and members.
## Features
- 🔐 **Token-based Authentication** - Secure access using API tokens
- 📧 **Mailing List Management** - Create, edit, and delete mailing lists
- 👥 **Member Management** - Add, edit, and remove members
- 🔗 **Subscription Management** - Subscribe/unsubscribe members to/from lists
- 📱 **Responsive Design** - Works on desktop, tablet, and mobile devices
- 🎨 **Clean UI** - Modern, professional interface with intuitive navigation
-**Real-time Updates** - Immediate feedback and data refresh
## Architecture
### Frontend Stack
- **HTML5** - Semantic markup with accessibility features
- **CSS3** - Modern styling with CSS Grid/Flexbox and custom properties
- **Vanilla JavaScript** - No frameworks, just clean ES6+ code
- **Nginx** - Static file serving with compression and caching
### File Structure
```
web/
├── index.html # Main HTML interface
├── static/
│ ├── css/
│ │ └── style.css # Complete styling system
│ └── js/
│ ├── api.js # API client for backend communication
│ ├── ui.js # UI helpers and modal management
│ └── app.js # Main application controller
├── Dockerfile # Container configuration
├── nginx.conf # Nginx server configuration
└── README.md # This file
```
## Usage
### Development
1. Ensure the API is running on port 8000
2. Open `index.html` in a web browser
3. Enter your API token to authenticate
4. Start managing your mailing lists!
### Production (Docker)
The web frontend is served using Nginx and included in the Docker Compose setup.
```bash
# Build and start all services
docker-compose up --build
# Access the web interface
open http://localhost:3000
```
## Features Overview
### Authentication
- Secure token-based authentication
- Token persistence in browser storage
- Automatic logout on authentication errors
### Mailing Lists
- View all mailing lists in a clean table
- Create new lists with name, email, and description
- Edit existing list details
- Delete lists with confirmation
- View member count for each list
- Status indicators (Active/Inactive)
### Members
- View all members with their details
- Add new members with name and email
- Edit member information
- Delete members with confirmation
- See which lists each member is subscribed to
- Status management
### Subscriptions
- Visual subscription management interface
- Add members to mailing lists
- Remove members from lists
- See all subscriptions organized by list
- Quick unsubscribe functionality
### User Experience
- **Responsive Design** - Works seamlessly on all device sizes
- **Loading States** - Visual feedback during API operations
- **Error Handling** - Clear error messages and graceful degradation
- **Notifications** - Success/error notifications with auto-dismiss
- **Confirmation Dialogs** - Prevent accidental deletions
- **Keyboard Shortcuts** - Enter key support in forms
- **Accessibility** - Semantic HTML and ARIA labels
## API Integration
The frontend communicates with the FastAPI backend through a comprehensive API client:
- **Authentication** - Bearer token authentication
- **Error Handling** - Automatic error parsing and user-friendly messages
- **Loading States** - Integrated loading indicators
- **Data Validation** - Client-side form validation before API calls
## Customization
### Styling
The CSS uses CSS custom properties (variables) for easy theming:
```css
:root {
--primary-color: #2563eb;
--success-color: #10b981;
--danger-color: #ef4444;
/* ... more variables */
}
```
### Configuration
The API client automatically detects the backend URL based on the current hostname and can be configured for different environments.
## Browser Support
- Modern browsers (Chrome 60+, Firefox 55+, Safari 12+, Edge 79+)
- Mobile browsers (iOS Safari 12+, Chrome Mobile 60+)
- Progressive enhancement for older browsers

271
web/index.html Normal file
View File

@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mailing List Manager</title>
<link rel="stylesheet" href="static/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<!-- Header -->
<header class="header">
<div class="container">
<div class="header-content">
<h1 class="logo">
<i class="fas fa-envelope"></i>
Mailing List Manager
</h1>
<div class="auth-section">
<div class="auth-controls" id="authControls">
<input type="password" id="apiToken" placeholder="Enter API Token" class="token-input">
<button class="btn btn-primary" id="loginBtn">Login</button>
</div>
<div class="user-info" id="userInfo" style="display: none;">
<span class="status-indicator">
<i class="fas fa-check-circle"></i>
Connected
</span>
<button class="btn btn-secondary" id="logoutBtn">Logout</button>
</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content" id="mainContent" style="display: none;">
<div class="container">
<!-- Navigation Tabs -->
<nav class="tab-nav">
<button class="tab-btn active" data-tab="lists">
<i class="fas fa-list"></i>
Mailing Lists
</button>
<button class="tab-btn" data-tab="members">
<i class="fas fa-users"></i>
Members
</button>
<button class="tab-btn" data-tab="subscriptions">
<i class="fas fa-link"></i>
Subscriptions
</button>
</nav>
<!-- Notification Area -->
<div class="notification" id="notification" style="display: none;">
<span class="notification-message" id="notificationMessage"></span>
<button class="notification-close" id="notificationClose">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Mailing Lists Tab -->
<div class="tab-content active" id="lists-tab">
<div class="section-header">
<h2>Mailing Lists</h2>
<button class="btn btn-primary" id="addListBtn">
<i class="fas fa-plus"></i>
Add List
</button>
</div>
<div class="data-table">
<table class="table" id="listsTable">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Description</th>
<th>Members</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="listsTableBody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<!-- Members Tab -->
<div class="tab-content" id="members-tab">
<div class="section-header">
<h2>Members</h2>
<button class="btn btn-primary" id="addMemberBtn">
<i class="fas fa-plus"></i>
Add Member
</button>
</div>
<div class="data-table">
<table class="table" id="membersTable">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Lists</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="membersTableBody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<!-- Subscriptions Tab -->
<div class="tab-content" id="subscriptions-tab">
<div class="section-header">
<h2>Manage Subscriptions</h2>
<button class="btn btn-primary" id="addSubscriptionBtn">
<i class="fas fa-plus"></i>
Add Subscription
</button>
</div>
<div class="subscriptions-grid" id="subscriptionsGrid">
<!-- Dynamic content -->
</div>
</div>
</div>
</main>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading...</p>
</div>
</div>
<!-- Modals -->
<!-- Add/Edit List Modal -->
<div class="modal" id="listModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="listModalTitle">Add Mailing List</h3>
<button class="modal-close" id="listModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<form id="listForm">
<div class="form-group">
<label for="listName">List Name *</label>
<input type="text" id="listName" name="listName" required>
</div>
<div class="form-group">
<label for="listEmail">List Email *</label>
<input type="email" id="listEmail" name="listEmail" required>
</div>
<div class="form-group">
<label for="listDescription">Description</label>
<textarea id="listDescription" name="listDescription" rows="3"></textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="listActive" name="listActive" checked>
<span class="checkmark"></span>
Active
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="listCancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary" id="listSubmitBtn">Save</button>
</div>
</form>
</div>
</div>
<!-- Add/Edit Member Modal -->
<div class="modal" id="memberModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="memberModalTitle">Add Member</h3>
<button class="modal-close" id="memberModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<form id="memberForm">
<div class="form-group">
<label for="memberName">Member Name *</label>
<input type="text" id="memberName" name="memberName" required>
</div>
<div class="form-group">
<label for="memberEmail">Email Address *</label>
<input type="email" id="memberEmail" name="memberEmail" required>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="memberActive" name="memberActive" checked>
<span class="checkmark"></span>
Active
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="memberCancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary" id="memberSubmitBtn">Save</button>
</div>
</form>
</div>
</div>
<!-- Add Subscription Modal -->
<div class="modal" id="subscriptionModal">
<div class="modal-content">
<div class="modal-header">
<h3>Add Subscription</h3>
<button class="modal-close" id="subscriptionModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<form id="subscriptionForm">
<div class="form-group">
<label for="subscriptionList">Mailing List *</label>
<select id="subscriptionList" name="subscriptionList" required>
<option value="">Select a list...</option>
</select>
</div>
<div class="form-group">
<label for="subscriptionMember">Member *</label>
<select id="subscriptionMember" name="subscriptionMember" required>
<option value="">Select a member...</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="subscriptionCancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary">Add Subscription</button>
</div>
</form>
</div>
</div>
<!-- Confirmation Modal -->
<div class="modal" id="confirmModal">
<div class="modal-content">
<div class="modal-header">
<h3>Confirm Action</h3>
<button class="modal-close" id="confirmModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<p id="confirmMessage">Are you sure you want to perform this action?</p>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="confirmCancelBtn">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmOkBtn">Confirm</button>
</div>
</div>
</div>
<script src="static/js/api.js"></script>
<script src="static/js/ui.js"></script>
<script src="static/js/app.js"></script>
</body>
</html>

44
web/nginx.conf Normal file
View File

@@ -0,0 +1,44 @@
server {
listen 80;
server_name localhost;
# Cache static assets - this needs to come BEFORE the main location block
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, no-transform";
}
# Serve static files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# Prevent access to hidden files
location ~ /\. {
deny all;
}
}

681
web/static/css/style.css Normal file
View File

@@ -0,0 +1,681 @@
/* CSS Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Color Palette */
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--secondary-color: #64748b;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--info-color: #3b82f6;
/* Neutral Colors */
--white: #ffffff;
--gray-50: #f8fafc;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-400: #94a3b8;
--gray-500: #64748b;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
/* Typography */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Border Radius */
--radius-sm: 0.375rem;
--radius: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Transitions */
--transition: all 0.2s ease-in-out;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
line-height: 1.6;
color: var(--gray-800);
background-color: var(--gray-50);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-4);
}
/* Header */
.header {
background: var(--white);
border-bottom: 1px solid var(--gray-200);
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) 0;
}
.logo {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--primary-color);
display: flex;
align-items: center;
gap: var(--space-2);
}
.logo i {
font-size: var(--font-size-2xl);
}
/* Authentication */
.auth-section {
display: flex;
align-items: center;
gap: var(--space-4);
}
.auth-controls {
display: flex;
align-items: center;
gap: var(--space-3);
}
.token-input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: var(--font-size-sm);
min-width: 200px;
transition: var(--transition);
}
.token-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: var(--space-3);
}
.status-indicator {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--success-color);
font-size: var(--font-size-sm);
font-weight: 500;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-color);
color: var(--white);
border-color: var(--primary-color);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-secondary {
background: var(--white);
color: var(--gray-700);
border-color: var(--gray-300);
}
.btn-secondary:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--gray-400);
}
.btn-danger {
background: var(--danger-color);
color: var(--white);
border-color: var(--danger-color);
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
border-color: #dc2626;
}
.btn-sm {
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-xs);
}
/* Main Content */
.main-content {
padding: var(--space-6) 0;
}
/* Tab Navigation */
.tab-nav {
display: flex;
gap: var(--space-1);
background: var(--white);
border-radius: var(--radius-lg);
padding: var(--space-1);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-8);
}
.tab-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: transparent;
border: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--gray-600);
cursor: pointer;
transition: var(--transition);
flex: 1;
justify-content: center;
}
.tab-btn:hover {
background: var(--gray-100);
color: var(--gray-800);
}
.tab-btn.active {
background: var(--primary-color);
color: var(--white);
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Section Header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
}
.section-header h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--gray-900);
}
/* Notifications */
.notification {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
margin-bottom: var(--space-6);
border-radius: var(--radius);
font-weight: 500;
}
.notification.success {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.notification.error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.notification.info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.notification-close {
background: none;
border: none;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
opacity: 0.7;
transition: var(--transition);
}
.notification-close:hover {
opacity: 1;
}
/* Tables */
.data-table {
background: var(--white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
background: var(--gray-50);
padding: var(--space-4);
text-align: left;
font-weight: 600;
color: var(--gray-700);
border-bottom: 1px solid var(--gray-200);
font-size: var(--font-size-sm);
}
.table td {
padding: var(--space-4);
border-bottom: 1px solid var(--gray-100);
font-size: var(--font-size-sm);
}
.table tbody tr:hover {
background: var(--gray-50);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-badge.active {
background: #dcfce7;
color: #166534;
}
.status-badge.inactive {
background: var(--gray-100);
color: var(--gray-600);
}
/* Action buttons in tables */
.action-buttons {
display: flex;
gap: var(--space-2);
}
/* Subscriptions Grid */
.subscriptions-grid {
display: grid;
gap: var(--space-6);
}
.subscription-list {
background: var(--white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.subscription-header {
background: var(--gray-50);
padding: var(--space-4);
border-bottom: 1px solid var(--gray-200);
}
.subscription-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--gray-900);
margin-bottom: var(--space-1);
}
.subscription-header p {
color: var(--gray-600);
font-size: var(--font-size-sm);
}
.subscription-members {
padding: var(--space-4);
}
.member-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--gray-100);
}
.member-item:last-child {
border-bottom: none;
}
.member-info {
flex: 1;
}
.member-name {
font-weight: 500;
color: var(--gray-900);
margin-bottom: var(--space-1);
}
.member-email {
color: var(--gray-600);
font-size: var(--font-size-sm);
}
/* Modals */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-6) var(--space-6) var(--space-4);
border-bottom: 1px solid var(--gray-200);
}
.modal-header h3 {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--gray-900);
}
.modal-close {
background: none;
border: none;
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius);
color: var(--gray-400);
transition: var(--transition);
}
.modal-close:hover {
background: var(--gray-100);
color: var(--gray-600);
}
.modal-body {
padding: var(--space-4) var(--space-6);
}
.modal-actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
padding: var(--space-4) var(--space-6) var(--space-6);
}
/* Forms */
.form-group {
margin-bottom: var(--space-5);
}
.form-group label {
display: block;
margin-bottom: var(--space-2);
font-weight: 500;
color: var(--gray-700);
font-size: var(--font-size-sm);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
/* Checkbox */
.checkbox-label {
display: flex !important;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-weight: 400 !important;
}
.checkbox-label input[type="checkbox"] {
width: auto !important;
margin: 0;
}
.checkmark {
font-size: var(--font-size-sm);
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
z-index: 2000;
align-items: center;
justify-content: center;
}
.loading-overlay.active {
display: flex;
}
.loading-spinner {
text-align: center;
color: var(--primary-color);
}
.loading-spinner i {
font-size: var(--font-size-3xl);
margin-bottom: var(--space-4);
}
.loading-spinner p {
font-weight: 500;
color: var(--gray-600);
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 0 var(--space-3);
}
.header-content {
flex-direction: column;
gap: var(--space-4);
align-items: stretch;
}
.auth-controls {
flex-direction: column;
gap: var(--space-2);
}
.token-input {
min-width: auto;
}
.tab-nav {
flex-direction: column;
}
.section-header {
flex-direction: column;
gap: var(--space-4);
align-items: stretch;
}
.table-responsive {
overflow-x: auto;
}
.table {
min-width: 600px;
}
.modal-content {
margin: var(--space-4);
max-width: calc(100vw - 2rem);
}
.modal-actions {
flex-direction: column;
}
.action-buttons {
flex-direction: column;
}
}
/* Utility Classes */
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-muted { color: var(--gray-600); }
.text-sm { font-size: var(--font-size-sm); }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.mb-0 { margin-bottom: 0; }
.mb-4 { margin-bottom: var(--space-4); }
.mt-4 { margin-top: var(--space-4); }
.hidden { display: none; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}

219
web/static/js/api.js Normal file
View File

@@ -0,0 +1,219 @@
/**
* API Client for Mailing List Manager
* Handles all communication with the FastAPI backend
*/
class APIClient {
constructor() {
this.baseURL = this.getBaseURL();
this.token = null;
this.headers = {
'Content-Type': 'application/json'
};
}
/**
* Get the base URL for API calls
* Automatically detects if running in container or development
*/
getBaseURL() {
const protocol = window.location.protocol;
const hostname = window.location.hostname;
// If running on localhost, assume development mode
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `${protocol}//${hostname}:8000`;
}
// If running in production, assume API is on port 8000
return `${protocol}//${hostname}:8000`;
}
/**
* Set authentication token
*/
setToken(token) {
this.token = token;
this.headers['Authorization'] = `Bearer ${token}`;
}
/**
* Clear authentication token
*/
clearToken() {
this.token = null;
delete this.headers['Authorization'];
}
/**
* Make HTTP request to API
*/
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
headers: { ...this.headers },
...options
};
try {
const response = await fetch(url, config);
// Handle different response types
if (response.status === 204) {
return null; // No content
}
const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
throw new APIError(
data.detail || data || `HTTP ${response.status}`,
response.status,
data
);
}
return data;
} catch (error) {
if (error instanceof APIError) {
throw error;
}
// Network or other errors
throw new APIError(
'Network error or API unavailable',
0,
{ originalError: error.message }
);
}
}
// Health and authentication methods
async checkHealth() {
return this.request('/health');
}
async testAuth() {
return this.request('/');
}
// Mailing Lists API
async getLists() {
return this.request('/lists');
}
async getList(listId) {
return this.request(`/lists/${listId}`);
}
async createList(listData) {
return this.request('/lists', {
method: 'POST',
body: JSON.stringify(listData)
});
}
async updateList(listId, listData) {
return this.request(`/lists/${listId}`, {
method: 'PATCH',
body: JSON.stringify(listData)
});
}
async deleteList(listId) {
return this.request(`/lists/${listId}`, {
method: 'DELETE'
});
}
// Members API
async getMembers() {
return this.request('/members');
}
async getMember(memberId) {
return this.request(`/members/${memberId}`);
}
async createMember(memberData) {
return this.request('/members', {
method: 'POST',
body: JSON.stringify(memberData)
});
}
async updateMember(memberId, memberData) {
return this.request(`/members/${memberId}`, {
method: 'PATCH',
body: JSON.stringify(memberData)
});
}
async deleteMember(memberId) {
return this.request(`/members/${memberId}`, {
method: 'DELETE'
});
}
// Subscriptions API
async getListMembers(listId) {
return this.request(`/lists/${listId}/members`);
}
async createSubscription(subscriptionData) {
return this.request('/subscriptions', {
method: 'POST',
body: JSON.stringify(subscriptionData)
});
}
async deleteSubscription(listEmail, memberEmail) {
const params = new URLSearchParams({
list_email: listEmail,
member_email: memberEmail
});
return this.request(`/subscriptions?${params}`, {
method: 'DELETE'
});
}
}
/**
* Custom API Error class
*/
class APIError extends Error {
constructor(message, status = 0, details = null) {
super(message);
this.name = 'APIError';
this.status = status;
this.details = details;
}
isAuthError() {
return this.status === 401;
}
isNotFound() {
return this.status === 404;
}
isBadRequest() {
return this.status === 400;
}
isServerError() {
return this.status >= 500;
}
}
// Create global API client instance
window.apiClient = new APIClient();
window.APIError = APIError;

477
web/static/js/app.js Normal file
View File

@@ -0,0 +1,477 @@
/**
* Main Application Controller
* Handles authentication, data loading, and view rendering
*/
class MailingListApp {
constructor() {
this.isAuthenticated = false;
this.lists = [];
this.members = [];
this.subscriptions = new Map(); // list_id -> members[]
this.initializeApp();
}
/**
* Initialize the application
*/
async initializeApp() {
this.setupEventListeners();
// Check for saved token
const savedToken = localStorage.getItem('apiToken');
if (savedToken) {
await this.login(savedToken, false);
}
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Login/logout
document.getElementById('loginBtn').addEventListener('click', () => {
this.handleLogin();
});
document.getElementById('logoutBtn').addEventListener('click', () => {
this.logout();
});
// Enter key in token input
document.getElementById('apiToken').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
}
/**
* Handle login button click
*/
async handleLogin() {
const tokenInput = document.getElementById('apiToken');
const token = tokenInput.value.trim();
if (!token) {
uiManager.showNotification('Please enter an API token', 'error');
return;
}
await this.login(token, true);
}
/**
* Authenticate with API
*/
async login(token, saveToken = true) {
try {
uiManager.setLoading(true);
// Set token and test authentication
apiClient.setToken(token);
await apiClient.testAuth();
// Authentication successful
this.isAuthenticated = true;
if (saveToken) {
localStorage.setItem('apiToken', token);
}
this.showAuthenticatedUI();
await this.loadData();
uiManager.showNotification('Successfully connected to API', 'success');
} catch (error) {
this.isAuthenticated = false;
apiClient.clearToken();
if (saveToken) {
localStorage.removeItem('apiToken');
}
uiManager.handleError(error, 'Authentication failed');
} finally {
uiManager.setLoading(false);
}
}
/**
* Logout
*/
logout() {
this.isAuthenticated = false;
apiClient.clearToken();
localStorage.removeItem('apiToken');
this.showUnauthenticatedUI();
uiManager.showNotification('Logged out successfully', 'info');
}
/**
* Show authenticated UI
*/
showAuthenticatedUI() {
document.getElementById('authControls').style.display = 'none';
document.getElementById('userInfo').style.display = 'flex';
document.getElementById('mainContent').style.display = 'block';
// Clear token input
document.getElementById('apiToken').value = '';
}
/**
* Show unauthenticated UI
*/
showUnauthenticatedUI() {
document.getElementById('authControls').style.display = 'flex';
document.getElementById('userInfo').style.display = 'none';
document.getElementById('mainContent').style.display = 'none';
}
/**
* Load all data from API
*/
async loadData() {
if (!this.isAuthenticated) return;
try {
uiManager.setLoading(true);
// Load lists and members in parallel
const [lists, members] = await Promise.all([
apiClient.getLists(),
apiClient.getMembers()
]);
this.lists = lists;
this.members = members;
// Load subscriptions for each list
await this.loadSubscriptions();
// Render all views
this.renderLists();
this.renderMembers();
this.renderSubscriptions();
} catch (error) {
uiManager.handleError(error, 'Failed to load data');
} finally {
uiManager.setLoading(false);
}
}
/**
* Load subscription data for all lists
*/
async loadSubscriptions() {
this.subscriptions.clear();
const subscriptionPromises = this.lists.map(async (list) => {
try {
const members = await apiClient.getListMembers(list.list_id);
this.subscriptions.set(list.list_id, members);
} catch (error) {
console.warn(`Failed to load members for list ${list.list_id}:`, error);
this.subscriptions.set(list.list_id, []);
}
});
await Promise.all(subscriptionPromises);
}
/**
* Render mailing lists table
*/
renderLists() {
const tbody = document.getElementById('listsTableBody');
tbody.innerHTML = '';
if (this.lists.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted">
No mailing lists found. <a href="#" id="createFirstList">Create your first list</a>
</td>
</tr>
`;
document.getElementById('createFirstList').addEventListener('click', (e) => {
e.preventDefault();
uiManager.showListModal();
});
return;
}
this.lists.forEach(list => {
const row = document.createElement('tr');
const memberCount = this.subscriptions.get(list.list_id)?.length || 0;
row.innerHTML = `
<td>
<div class="font-medium">${uiManager.escapeHtml(list.list_name)}</div>
</td>
<td>
<a href="mailto:${list.list_email}" style="color: var(--primary-color)">
${uiManager.escapeHtml(list.list_email)}
</a>
</td>
<td>
<div class="text-sm text-muted">
${list.description ? uiManager.escapeHtml(list.description) : '<em>No description</em>'}
</div>
</td>
<td>
<span class="font-medium">${memberCount}</span>
${memberCount === 1 ? 'member' : 'members'}
</td>
<td></td>
<td>
<div class="action-buttons"></div>
</td>
`;
// Add status badge
const statusCell = row.cells[4];
statusCell.appendChild(uiManager.createStatusBadge(list.active));
// Add action buttons
const actionsCell = row.cells[5].querySelector('.action-buttons');
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {
uiManager.showListModal(list);
});
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
uiManager.showConfirmation(
`Are you sure you want to delete the mailing list "${list.list_name}"? This action cannot be undone.`,
async () => {
await this.deleteList(list.list_id);
}
);
});
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
tbody.appendChild(row);
});
}
/**
* Render members table
*/
renderMembers() {
const tbody = document.getElementById('membersTableBody');
tbody.innerHTML = '';
if (this.members.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="text-center text-muted">
No members found. <a href="#" id="createFirstMember">Add your first member</a>
</td>
</tr>
`;
document.getElementById('createFirstMember').addEventListener('click', (e) => {
e.preventDefault();
uiManager.showMemberModal();
});
return;
}
this.members.forEach(member => {
const row = document.createElement('tr');
// Find lists this member belongs to
const memberLists = [];
this.subscriptions.forEach((members, listId) => {
if (members.some(m => m.member_id === member.member_id)) {
const list = this.lists.find(l => l.list_id === listId);
if (list) {
memberLists.push(list.list_name);
}
}
});
row.innerHTML = `
<td>
<div class="font-medium">${uiManager.escapeHtml(member.name)}</div>
</td>
<td>
<a href="mailto:${member.email}" style="color: var(--primary-color)">
${uiManager.escapeHtml(member.email)}
</a>
</td>
<td>
<div class="text-sm">
${memberLists.length > 0
? memberLists.map(name => `<span class="text-muted">${uiManager.escapeHtml(name)}</span>`).join(', ')
: '<em class="text-muted">No subscriptions</em>'
}
</div>
</td>
<td></td>
<td>
<div class="action-buttons"></div>
</td>
`;
// Add status badge
const statusCell = row.cells[3];
statusCell.appendChild(uiManager.createStatusBadge(member.active));
// Add action buttons
const actionsCell = row.cells[4].querySelector('.action-buttons');
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {
uiManager.showMemberModal(member);
});
const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
uiManager.showConfirmation(
`Are you sure you want to delete the member "${member.name}"? This will remove them from all mailing lists.`,
async () => {
await this.deleteMember(member.member_id);
}
);
});
actionsCell.appendChild(editBtn);
actionsCell.appendChild(deleteBtn);
tbody.appendChild(row);
});
}
/**
* Render subscriptions view
*/
renderSubscriptions() {
const container = document.getElementById('subscriptionsGrid');
container.innerHTML = '';
if (this.lists.length === 0) {
container.innerHTML = `
<div class="text-center text-muted">
<p>No mailing lists available.</p>
<button class="btn btn-primary mt-4" onclick="uiManager.switchTab('lists')">
<i class="fas fa-plus"></i> Create Mailing List
</button>
</div>
`;
return;
}
this.lists.forEach(list => {
const members = this.subscriptions.get(list.list_id) || [];
const listCard = document.createElement('div');
listCard.className = 'subscription-list';
listCard.innerHTML = `
<div class="subscription-header">
<h3>${uiManager.escapeHtml(list.list_name)}</h3>
<p>${uiManager.escapeHtml(list.list_email)}</p>
</div>
<div class="subscription-members">
<div class="members-list"></div>
</div>
`;
const membersList = listCard.querySelector('.members-list');
if (members.length === 0) {
membersList.innerHTML = `
<div class="text-center text-muted">
<p>No members subscribed to this list.</p>
</div>
`;
} else {
members.forEach(member => {
const memberItem = document.createElement('div');
memberItem.className = 'member-item';
memberItem.innerHTML = `
<div class="member-info">
<div class="member-name">${uiManager.escapeHtml(member.name)}</div>
<div class="member-email">${uiManager.escapeHtml(member.email)}</div>
</div>
<button class="btn btn-sm btn-danger" title="Unsubscribe">
<i class="fas fa-times"></i>
</button>
`;
// Add unsubscribe functionality
const unsubscribeBtn = memberItem.querySelector('.btn-danger');
unsubscribeBtn.addEventListener('click', () => {
uiManager.showConfirmation(
`Unsubscribe ${member.name} from ${list.list_name}?`,
async () => {
await this.unsubscribeMember(list.list_email, member.email);
}
);
});
membersList.appendChild(memberItem);
});
}
container.appendChild(listCard);
});
}
/**
* Delete a mailing list
*/
async deleteList(listId) {
try {
uiManager.setLoading(true);
await apiClient.deleteList(listId);
uiManager.showNotification('Mailing list deleted successfully', 'success');
await this.loadData();
} catch (error) {
uiManager.handleError(error, 'Failed to delete mailing list');
} finally {
uiManager.setLoading(false);
}
}
/**
* Delete a member
*/
async deleteMember(memberId) {
try {
uiManager.setLoading(true);
await apiClient.deleteMember(memberId);
uiManager.showNotification('Member deleted successfully', 'success');
await this.loadData();
} catch (error) {
uiManager.handleError(error, 'Failed to delete member');
} finally {
uiManager.setLoading(false);
}
}
/**
* Unsubscribe member from list
*/
async unsubscribeMember(listEmail, memberEmail) {
try {
uiManager.setLoading(true);
await apiClient.deleteSubscription(listEmail, memberEmail);
uiManager.showNotification('Member unsubscribed successfully', 'success');
await this.loadData();
} catch (error) {
uiManager.handleError(error, 'Failed to unsubscribe member');
} finally {
uiManager.setLoading(false);
}
}
}
// Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.app = new MailingListApp();
});

462
web/static/js/ui.js Normal file
View File

@@ -0,0 +1,462 @@
/**
* UI Helper Functions and Components
* Handles DOM manipulation, notifications, modals, and UI state
*/
class UIManager {
constructor() {
this.currentTab = 'lists';
this.currentEditingItem = null;
this.isLoading = false;
this.confirmCallback = null;
this.initializeEventListeners();
}
/**
* Initialize all event listeners
*/
initializeEventListeners() {
// Tab navigation
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Modal close buttons
document.querySelectorAll('.modal-close, .modal .btn-secondary').forEach(btn => {
btn.addEventListener('click', (e) => {
this.closeModal(e.target.closest('.modal'));
});
});
// Click outside modal to close
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.closeModal(modal);
}
});
});
// Notification close
document.getElementById('notificationClose').addEventListener('click', () => {
this.hideNotification();
});
// Add buttons
document.getElementById('addListBtn').addEventListener('click', () => {
this.showListModal();
});
document.getElementById('addMemberBtn').addEventListener('click', () => {
this.showMemberModal();
});
document.getElementById('addSubscriptionBtn').addEventListener('click', () => {
this.showSubscriptionModal();
});
// Form submissions
document.getElementById('listForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleListFormSubmit();
});
document.getElementById('memberForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleMemberFormSubmit();
});
document.getElementById('subscriptionForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleSubscriptionFormSubmit();
});
// Confirmation modal
document.getElementById('confirmOkBtn').addEventListener('click', () => {
if (this.confirmCallback) {
this.confirmCallback();
this.confirmCallback = null;
}
this.closeModal(document.getElementById('confirmModal'));
});
document.getElementById('confirmCancelBtn').addEventListener('click', () => {
this.confirmCallback = null;
this.closeModal(document.getElementById('confirmModal'));
});
}
/**
* Show/hide loading overlay
*/
setLoading(loading) {
this.isLoading = loading;
const overlay = document.getElementById('loadingOverlay');
if (loading) {
overlay.style.display = 'flex';
} else {
overlay.style.display = 'none';
}
}
/**
* Show notification
*/
showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
const messageEl = document.getElementById('notificationMessage');
notification.className = `notification ${type}`;
messageEl.textContent = message;
notification.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
this.hideNotification();
}, 5000);
}
/**
* Hide notification
*/
hideNotification() {
document.getElementById('notification').style.display = 'none';
}
/**
* Show confirmation dialog
*/
showConfirmation(message, callback) {
document.getElementById('confirmMessage').textContent = message;
this.confirmCallback = callback;
this.showModal(document.getElementById('confirmModal'));
}
/**
* Switch between tabs
*/
switchTab(tabName) {
this.currentTab = tabName;
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === `${tabName}-tab`);
});
}
/**
* Show modal
*/
showModal(modal) {
modal.classList.add('active');
// Focus first input
const firstInput = modal.querySelector('input, select, textarea');
if (firstInput) {
setTimeout(() => firstInput.focus(), 100);
}
}
/**
* Close modal
*/
closeModal(modal) {
modal.classList.remove('active');
this.currentEditingItem = null;
// Reset forms
const form = modal.querySelector('form');
if (form) {
form.reset();
}
}
/**
* Show list modal (add/edit)
*/
showListModal(listData = null) {
const modal = document.getElementById('listModal');
const title = document.getElementById('listModalTitle');
const form = document.getElementById('listForm');
if (listData) {
// Edit mode
title.textContent = 'Edit Mailing List';
document.getElementById('listName').value = listData.list_name;
document.getElementById('listEmail').value = listData.list_email;
document.getElementById('listDescription').value = listData.description || '';
document.getElementById('listActive').checked = listData.active;
this.currentEditingItem = listData;
} else {
// Add mode
title.textContent = 'Add Mailing List';
form.reset();
document.getElementById('listActive').checked = true;
this.currentEditingItem = null;
}
this.showModal(modal);
}
/**
* Show member modal (add/edit)
*/
showMemberModal(memberData = null) {
const modal = document.getElementById('memberModal');
const title = document.getElementById('memberModalTitle');
const form = document.getElementById('memberForm');
if (memberData) {
// Edit mode
title.textContent = 'Edit Member';
document.getElementById('memberName').value = memberData.name;
document.getElementById('memberEmail').value = memberData.email;
document.getElementById('memberActive').checked = memberData.active;
this.currentEditingItem = memberData;
} else {
// Add mode
title.textContent = 'Add Member';
form.reset();
document.getElementById('memberActive').checked = true;
this.currentEditingItem = null;
}
this.showModal(modal);
}
/**
* Show subscription modal
*/
async showSubscriptionModal() {
const modal = document.getElementById('subscriptionModal');
try {
// Populate dropdowns
await this.populateSubscriptionDropdowns();
this.showModal(modal);
} catch (error) {
this.showNotification('Failed to load subscription data', 'error');
}
}
/**
* Populate subscription modal dropdowns
*/
async populateSubscriptionDropdowns() {
const listSelect = document.getElementById('subscriptionList');
const memberSelect = document.getElementById('subscriptionMember');
// Clear existing options
listSelect.innerHTML = '<option value="">Select a list...</option>';
memberSelect.innerHTML = '<option value="">Select a member...</option>';
try {
const [lists, members] = await Promise.all([
apiClient.getLists(),
apiClient.getMembers()
]);
// Populate lists
lists.forEach(list => {
if (list.active) {
const option = document.createElement('option');
option.value = list.list_email;
option.textContent = `${list.list_name} (${list.list_email})`;
listSelect.appendChild(option);
}
});
// Populate members
members.forEach(member => {
if (member.active) {
const option = document.createElement('option');
option.value = member.email;
option.textContent = `${member.name} (${member.email})`;
memberSelect.appendChild(option);
}
});
} catch (error) {
throw error;
}
}
/**
* Handle list form submission
*/
async handleListFormSubmit() {
const form = document.getElementById('listForm');
const formData = new FormData(form);
const listData = {
list_name: formData.get('listName'),
list_email: formData.get('listEmail'),
description: formData.get('listDescription') || null,
active: formData.get('listActive') === 'on'
};
try {
this.setLoading(true);
if (this.currentEditingItem) {
// Update existing list
await apiClient.updateList(this.currentEditingItem.list_id, listData);
this.showNotification('Mailing list updated successfully', 'success');
} else {
// Create new list
await apiClient.createList(listData);
this.showNotification('Mailing list created successfully', 'success');
}
this.closeModal(document.getElementById('listModal'));
await window.app.loadData();
} catch (error) {
this.handleError(error, 'Failed to save mailing list');
} finally {
this.setLoading(false);
}
}
/**
* Handle member form submission
*/
async handleMemberFormSubmit() {
const form = document.getElementById('memberForm');
const formData = new FormData(form);
const memberData = {
name: formData.get('memberName'),
email: formData.get('memberEmail'),
active: formData.get('memberActive') === 'on'
};
try {
this.setLoading(true);
if (this.currentEditingItem) {
// Update existing member
await apiClient.updateMember(this.currentEditingItem.member_id, memberData);
this.showNotification('Member updated successfully', 'success');
} else {
// Create new member
await apiClient.createMember(memberData);
this.showNotification('Member created successfully', 'success');
}
this.closeModal(document.getElementById('memberModal'));
await window.app.loadData();
} catch (error) {
this.handleError(error, 'Failed to save member');
} finally {
this.setLoading(false);
}
}
/**
* Handle subscription form submission
*/
async handleSubscriptionFormSubmit() {
const form = document.getElementById('subscriptionForm');
const formData = new FormData(form);
const subscriptionData = {
list_email: formData.get('subscriptionList'),
member_email: formData.get('subscriptionMember'),
active: true
};
try {
this.setLoading(true);
await apiClient.createSubscription(subscriptionData);
this.showNotification('Subscription created successfully', 'success');
this.closeModal(document.getElementById('subscriptionModal'));
await window.app.loadData();
} catch (error) {
this.handleError(error, 'Failed to create subscription');
} finally {
this.setLoading(false);
}
}
/**
* Handle API errors
*/
handleError(error, defaultMessage = 'An error occurred') {
let message = defaultMessage;
if (error instanceof APIError) {
if (error.isAuthError()) {
message = 'Authentication failed. Please check your API token.';
window.app.logout();
} else if (error.isBadRequest()) {
message = error.message || 'Invalid request data';
} else if (error.isNotFound()) {
message = 'Resource not found';
} else if (error.isServerError()) {
message = 'Server error. Please try again later.';
} else {
message = error.message || defaultMessage;
}
} else {
message = error.message || defaultMessage;
}
this.showNotification(message, 'error');
console.error('Error:', error);
}
/**
* Create action button
*/
createActionButton(text, icon, className, onClick) {
const button = document.createElement('button');
button.className = `btn btn-sm ${className}`;
button.innerHTML = `<i class="fas fa-${icon}"></i> ${text}`;
button.addEventListener('click', onClick);
return button;
}
/**
* Create status badge
*/
createStatusBadge(active) {
const badge = document.createElement('span');
badge.className = `status-badge ${active ? 'active' : 'inactive'}`;
badge.innerHTML = `
<i class="fas fa-${active ? 'check' : 'times'}"></i>
${active ? 'Active' : 'Inactive'}
`;
return badge;
}
/**
* Format email as mailto link
*/
createEmailLink(email) {
const link = document.createElement('a');
link.href = `mailto:${email}`;
link.textContent = email;
link.style.color = 'var(--primary-color)';
return link;
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Create global UI manager instance
window.uiManager = new UIManager();