From b34ea2ed84fabcac2adce0e7cbbfc1b25e8e48ed Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 13 Oct 2025 20:05:08 +0000 Subject: [PATCH] Dynamic search --- web/index.html | 20 +++++ web/static/css/style.css | 119 ++++++++++++++++++++++++++ web/static/js/app.js | 175 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+) 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 @@ + +
+
+
+ + + +
+ +
+
+
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