diff --git a/web/index.html b/web/index.html
index 3e32556..c324952 100644
--- a/web/index.html
+++ b/web/index.html
@@ -168,6 +168,26 @@
+
+
+
+
+
+
+
+
+
+ 0 members found
+
+
+
+
diff --git a/web/static/css/style.css b/web/static/css/style.css
index 2dccf89..6105e8f 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -683,6 +683,113 @@ body {
color: var(--info-color);
}
+/* Search Section */
+.search-section {
+ margin-bottom: var(--space-6);
+}
+
+.search-container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.search-input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ max-width: 400px;
+}
+
+.search-input {
+ width: 100%;
+ padding: var(--space-3) var(--space-4) var(--space-3) var(--space-10);
+ border: 1px solid var(--gray-300);
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+ transition: var(--transition);
+ background: var(--white);
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
+}
+
+.search-input:not(:placeholder-shown) {
+ padding-right: var(--space-10);
+}
+
+.search-icon {
+ position: absolute;
+ left: var(--space-3);
+ color: var(--gray-400);
+ font-size: var(--font-size-sm);
+ pointer-events: none;
+ z-index: 1;
+}
+
+.search-clear {
+ position: absolute;
+ right: var(--space-3);
+ background: transparent;
+ border: none;
+ color: var(--gray-400);
+ cursor: pointer;
+ padding: var(--space-1);
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition);
+}
+
+.search-clear:hover {
+ color: var(--gray-600);
+ background: var(--gray-100);
+}
+
+.search-results-info {
+ font-size: var(--font-size-sm);
+ color: var(--gray-600);
+ font-weight: 500;
+}
+
+.search-results-info #memberSearchCount {
+ color: var(--primary-color);
+ font-weight: 600;
+}
+
+/* No results message */
+.no-results {
+ text-align: center;
+ padding: var(--space-8) var(--space-4);
+ color: var(--gray-500);
+}
+
+.no-results i {
+ font-size: var(--font-size-3xl);
+ color: var(--gray-300);
+ margin-bottom: var(--space-4);
+}
+
+.no-results h3 {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ margin-bottom: var(--space-2);
+ color: var(--gray-700);
+}
+
+.no-results p {
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-4);
+}
+
+.no-results .btn {
+ margin-top: var(--space-2);
+}
+
/* Notifications */
.notification {
display: flex;
@@ -1153,6 +1260,18 @@ body {
gap: var(--space-4);
align-items: stretch;
}
+
+ .search-input-wrapper {
+ max-width: none;
+ }
+
+ .search-input {
+ font-size: 16px; /* Prevent zoom on iOS */
+ }
+
+ .search-container {
+ margin-bottom: var(--space-4);
+ }
.table-responsive {
overflow-x: auto;
diff --git a/web/static/js/app.js b/web/static/js/app.js
index 771aafd..8f8a85b 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -97,6 +97,38 @@ class MailingListApp {
// Switch to users tab
this.switchToUsersTab();
});
+
+ // Member search functionality
+ const memberSearchInput = document.getElementById('memberSearchInput');
+ const memberSearchClear = document.getElementById('memberSearchClear');
+
+ memberSearchInput.addEventListener('input', (e) => {
+ this.filterMembers(e.target.value);
+
+ // Show/hide clear button
+ if (e.target.value.trim()) {
+ memberSearchClear.style.display = 'flex';
+ } else {
+ memberSearchClear.style.display = 'none';
+ }
+ });
+
+ memberSearchClear.addEventListener('click', () => {
+ memberSearchInput.value = '';
+ memberSearchClear.style.display = 'none';
+ this.filterMembers('');
+ memberSearchInput.focus();
+ });
+
+ // Clear search when switching tabs
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ if (btn.dataset.tab !== 'members') {
+ memberSearchInput.value = '';
+ memberSearchClear.style.display = 'none';
+ }
+ });
+ });
}
/**
@@ -728,6 +760,149 @@ class MailingListApp {
uiManager.setLoading(false);
}
}
+
+ /**
+ * Filter members by search term (name or email)
+ */
+ filterMembers(searchTerm) {
+ const searchInfo = document.getElementById('memberSearchInfo');
+ const searchCount = document.getElementById('memberSearchCount');
+ const tbody = document.getElementById('membersTableBody');
+
+ if (!this.members || !Array.isArray(this.members)) {
+ return;
+ }
+
+ // If no search term, show all members
+ if (!searchTerm || searchTerm.trim() === '') {
+ this.renderMembers();
+ searchInfo.style.display = 'none';
+ return;
+ }
+
+ const normalizedSearch = searchTerm.trim().toLowerCase();
+
+ // Filter members by name or email
+ const filteredMembers = this.members.filter(member => {
+ const name = (member.name || '').toLowerCase();
+ const email = (member.email || '').toLowerCase();
+ return name.includes(normalizedSearch) || email.includes(normalizedSearch);
+ });
+
+ // Update search results info
+ searchCount.textContent = filteredMembers.length;
+ searchInfo.style.display = 'block';
+
+ // Render filtered results
+ this.renderFilteredMembers(filteredMembers);
+ }
+
+ /**
+ * Render filtered members (similar to renderMembers but with filtered data)
+ */
+ renderFilteredMembers(filteredMembers) {
+ const tbody = document.getElementById('membersTableBody');
+ tbody.innerHTML = '';
+
+ if (filteredMembers.length === 0) {
+ // Show no results message
+ const row = tbody.insertRow();
+ const cell = row.insertCell();
+ cell.colSpan = 5;
+ cell.className = 'no-results';
+ cell.innerHTML = `
+
+ No members found
+ Try adjusting your search terms or check the spelling.
+ `;
+ return;
+ }
+
+ filteredMembers.forEach(member => {
+ const row = tbody.insertRow();
+
+ // Name
+ const nameCell = row.insertCell();
+ nameCell.textContent = member.name;
+
+ // Email
+ const emailCell = row.insertCell();
+ const emailLink = document.createElement('a');
+ emailLink.href = `mailto:${member.email}`;
+ emailLink.textContent = member.email;
+ emailLink.style.color = 'var(--primary-color)';
+ emailCell.appendChild(emailLink);
+
+ // Lists (show member's subscriptions)
+ const listsCell = row.insertCell();
+ 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);
+ }
+ }
+ });
+ listsCell.textContent = memberLists.length > 0 ? memberLists.join(', ') : 'None';
+
+ // Status
+ const statusCell = row.insertCell();
+ const statusBadge = document.createElement('span');
+ statusBadge.className = `status-badge ${member.active ? 'active' : 'inactive'}`;
+ statusBadge.innerHTML = ` ${member.active ? 'Active' : 'Inactive'}`;
+
+ // Add bounce status if exists
+ if (member.bounce_status && member.bounce_status !== 'clean') {
+ const bounceIndicator = document.createElement('span');
+ bounceIndicator.className = `bounce-badge bounce-${member.bounce_status === 'hard_bounce' ? 'hard' : 'soft'}`;
+ bounceIndicator.innerHTML = ` Bounces`;
+ bounceIndicator.title = `${member.bounce_count || 0} bounces - ${member.bounce_status}`;
+ statusCell.appendChild(document.createElement('br'));
+ statusCell.appendChild(bounceIndicator);
+ }
+
+ statusCell.appendChild(statusBadge);
+
+ // Actions
+ const actionsCell = row.insertCell();
+ actionsCell.className = 'action-buttons';
+
+ // Bounces button (if member has bounce data)
+ if (member.bounce_count > 0) {
+ const bouncesBtn = uiManager.createActionButton('Bounces', 'exclamation-triangle', 'btn-warning', () => {
+ uiManager.showBounceHistory(member);
+ });
+ actionsCell.appendChild(bouncesBtn);
+ }
+
+ const subscriptionsBtn = uiManager.createActionButton('Lists', 'list', 'btn-secondary', () => {
+ uiManager.showMemberSubscriptions(member);
+ });
+ actionsCell.appendChild(subscriptionsBtn);
+
+ const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-primary', () => {
+ uiManager.showMemberModal(member);
+ });
+ editBtn.setAttribute('data-requires-write', '');
+ actionsCell.appendChild(editBtn);
+
+ const deleteBtn = uiManager.createActionButton('Delete', 'trash', 'btn-danger', () => {
+ uiManager.showConfirmation(
+ `Are you sure you want to delete member "${member.name}"? This will also remove them from all mailing lists.`,
+ async () => {
+ await this.deleteMember(member.member_id);
+ }
+ );
+ });
+ deleteBtn.setAttribute('data-requires-write', '');
+ actionsCell.appendChild(deleteBtn);
+ });
+
+ // Update UI permissions for the filtered results
+ const hasWriteAccess = this.currentUser && (this.currentUser.role === 'administrator' || this.currentUser.role === 'operator');
+ this.updateUIForPermissions(hasWriteAccess);
+ }
}
// Initialize the application when DOM is loaded