diff --git a/api/main.py b/api/main.py
index 692b0e1..7da84d0 100644
--- a/api/main.py
+++ b/api/main.py
@@ -3,6 +3,7 @@ Mailing List Management API
FastAPI-based REST API for managing mailing lists and members
"""
from fastapi import FastAPI, HTTPException, Depends, Header
+from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from typing import List, Optional
@@ -26,6 +27,15 @@ app = FastAPI(
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()
# Database connection
diff --git a/docker-compose.yaml b/docker-compose.yaml
index f3d1423..5667de8 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -48,5 +48,15 @@ services:
networks:
- maillist-internal
+ web:
+ build: ./web
+ container_name: maillist-web
+ depends_on:
+ - api
+ ports:
+ - "3000:80"
+ networks:
+ - maillist-internal
+
volumes:
mysql_data:
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..00acdee
--- /dev/null
+++ b/web/Dockerfile
@@ -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;"]
\ No newline at end of file
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..336a5c0
--- /dev/null
+++ b/web/README.md
@@ -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
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..320f4ec
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,271 @@
+
+
+
+
+
+ Mailing List Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mailing Lists
+
+
+
+ Members
+
+
+
+ Subscriptions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ Email
+ Description
+ Members
+ Status
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ Email
+ Lists
+ Status
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mailing List *
+
+ Select a list...
+
+
+
+ Member *
+
+ Select a member...
+
+
+
+ Cancel
+ Add Subscription
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to perform this action?
+
+
+ Cancel
+ Confirm
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/nginx.conf b/web/nginx.conf
new file mode 100644
index 0000000..71a7170
--- /dev/null
+++ b/web/nginx.conf
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/web/static/css/style.css b/web/static/css/style.css
new file mode 100644
index 0000000..8401cf3
--- /dev/null
+++ b/web/static/css/style.css
@@ -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;
+}
\ No newline at end of file
diff --git a/web/static/js/api.js b/web/static/js/api.js
new file mode 100644
index 0000000..bae5b62
--- /dev/null
+++ b/web/static/js/api.js
@@ -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;
\ No newline at end of file
diff --git a/web/static/js/app.js b/web/static/js/app.js
new file mode 100644
index 0000000..b5fb6c7
--- /dev/null
+++ b/web/static/js/app.js
@@ -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 = `
+
+
+ No mailing lists found. Create your first list
+
+
+ `;
+
+ 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 = `
+
+ ${uiManager.escapeHtml(list.list_name)}
+
+
+
+ ${uiManager.escapeHtml(list.list_email)}
+
+
+
+
+ ${list.description ? uiManager.escapeHtml(list.description) : 'No description '}
+
+
+
+ ${memberCount}
+ ${memberCount === 1 ? 'member' : 'members'}
+
+
+
+
+
+ `;
+
+ // 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 = `
+
+
+ No members found. Add your first member
+
+
+ `;
+
+ 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 = `
+
+ ${uiManager.escapeHtml(member.name)}
+
+
+
+ ${uiManager.escapeHtml(member.email)}
+
+
+
+
+ ${memberLists.length > 0
+ ? memberLists.map(name => `${uiManager.escapeHtml(name)} `).join(', ')
+ : 'No subscriptions '
+ }
+
+
+
+
+
+
+ `;
+
+ // 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 = `
+
+
No mailing lists available.
+
+ Create Mailing List
+
+
+ `;
+ return;
+ }
+
+ this.lists.forEach(list => {
+ const members = this.subscriptions.get(list.list_id) || [];
+ const listCard = document.createElement('div');
+ listCard.className = 'subscription-list';
+
+ listCard.innerHTML = `
+
+
+ `;
+
+ const membersList = listCard.querySelector('.members-list');
+
+ if (members.length === 0) {
+ membersList.innerHTML = `
+
+
No members subscribed to this list.
+
+ `;
+ } else {
+ members.forEach(member => {
+ const memberItem = document.createElement('div');
+ memberItem.className = 'member-item';
+
+ memberItem.innerHTML = `
+
+
${uiManager.escapeHtml(member.name)}
+
${uiManager.escapeHtml(member.email)}
+
+
+
+
+ `;
+
+ // 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();
+});
\ No newline at end of file
diff --git a/web/static/js/ui.js b/web/static/js/ui.js
new file mode 100644
index 0000000..5034734
--- /dev/null
+++ b/web/static/js/ui.js
@@ -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 = 'Select a list... ';
+ memberSelect.innerHTML = 'Select a member... ';
+
+ 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 = ` ${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 = `
+
+ ${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();
\ No newline at end of file