sentinel-hawk / components /notification-log.js
hakandinger's picture
Notifications Screen Prompt
4db53ab verified
class NotificationLog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.notifications = [];
this.filteredNotifications = [];
this.isLoading = false;
this.channelFilter = 'all';
this.statusFilter = 'all';
}
static get observedAttributes() {
return ['loading', 'channel-filter', 'status-filter'];
}
connectedCallback() {
this.loadNotifications();
}
async loadNotifications() {
this.isLoading = true;
this.render();
try {
// Simulated API call
await new Promise(r => setTimeout(r, 600));
this.notifications = [
{
id: 1,
channel: 'telegram',
recipient: '-1001234567890',
message: 'Alert: Suspicious activity detected on @elonmusk',
status: 'sent',
error: null,
createdAt: '2024-03-20T10:30:00Z'
},
{
id: 2,
channel: 'whatsapp',
recipient: '+1234567890',
message: 'Alert: New post from monitored account @sama',
status: 'sent',
error: null,
createdAt: '2024-03-20T09:15:00Z'
},
{
id: 3,
channel: 'telegram',
recipient: '-1001234567890',
message: 'Alert: Status change on account +1-555-0123',
status: 'failed',
error: 'Chat not found',
createdAt: '2024-03-20T08:45:00Z'
},
{
id: 4,
channel: 'whatsapp',
recipient: '+1234567890',
message: 'Test message: WhatsApp configuration verified',
status: 'pending',
error: null,
createdAt: '2024-03-20T08:30:00Z'
},
{
id: 5,
channel: 'telegram',
recipient: '-1001234567890',
message: 'Alert: Multiple failed login attempts detected',
status: 'sent',
error: null,
createdAt: '2024-03-19T22:00:00Z'
},
{
id: 6,
channel: 'whatsapp',
recipient: '+1234567890',
message: 'Alert: Account @naval posted flagged content',
status: 'sent',
error: null,
createdAt: '2024-03-19T18:30:00Z'
}
];
this.isLoading = false;
this.applyFilters();
} catch (err) {
this.isLoading = false;
this.dispatchEvent(new CustomEvent('toast', {
detail: { type: 'error', title: 'Error', message: 'Failed to load notifications' },
bubbles: true,
composed: true
}));
this.render();
}
}
applyFilters() {
this.filteredNotifications = this.notifications.filter(n => {
const matchChannel = this.channelFilter === 'all' || n.channel === this.channelFilter;
const matchStatus = this.statusFilter === 'all' || n.status === this.statusFilter;
return matchChannel && matchStatus;
});
this.render();
}
onChannelChange(value) {
this.channelFilter = value;
this.applyFilters();
}
onStatusChange(value) {
this.statusFilter = value;
this.applyFilters();
}
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
timeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
let interval = Math.floor(seconds / 86400);
if (interval > 1) return `${interval} days ago`;
if (interval === 1) return 'Yesterday';
interval = Math.floor(seconds / 3600);
if (interval > 1) return `${interval} hours ago`;
if (interval === 1) return '1 hour ago';
interval = Math.floor(seconds / 60);
if (interval > 1) return `${interval} minutes ago`;
return 'Just now';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getStatusConfig(status) {
const configs = {
sent: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500' },
failed: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' }
};
return configs[status] || configs.pending;
}
render() {
const loadingHtml = `
<div class="py-16">
<div class="flex flex-col items-center justify-center">
<div class="w-12 h-12 border-4 border-amber-200 border-t-amber-500 rounded-full animate-spin"></div>
<p class="mt-4 text-gray-600 text-sm">Loading notifications...</p>
</div>
</div>
`;
const emptyHtml = `
<div class="py-16">
<div class="flex flex-col items-center justify-center text-center px-4">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"></path><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">No notifications found</h3>
<p class="text-gray-600 text-sm">No notifications match your filter criteria</p>
</div>
</div>
`;
const tableHtml = this.filteredNotifications.length > 0 ? `
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Channel</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Recipient</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Message</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Error</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
${this.filteredNotifications.map((n, i) => {
const sc = this.getStatusConfig(n.status);
return `
<tr class="hover:bg-gray-50/80 transition-colors duration-200" style="animation: fadeIn 0.3s ease-out ${i * 0.03}s both;">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">${this.formatDate(n.createdAt)}</div>
<div class="text-xs text-gray-500">${this.timeAgo(n.createdAt)}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center gap-1.5 text-sm font-medium capitalize">
${n.channel === 'telegram'
? '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>'}
${n.channel}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-gray-700 bg-gray-100 px-2 py-1 rounded">${n.recipient}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${sc.bg} ${sc.text}">
<span class="w-1.5 h-1.5 ${sc.dot} rounded-full mr-1.5 ${n.status === 'pending' ? 'animate-pulse' : ''}"></span>
${n.status.charAt(0).toUpperCase() + n.status.slice(1)}
</span>
</td>
<td class="px-6 py-4">
<p class="text-sm text-gray-700 max-w-xs truncate" title="${this.escapeHtml(n.message)}">${this.escapeHtml(n.message)}</p>
</td>
<td class="px-6 py-4">
${n.error ? `<span class="text-sm text-red-600" title="${this.escapeHtml(n.error)}">${this.escapeHtml(n.error.slice(0, 30))}${n.error.length > 30 ? '...' : ''}</span>` : '<span class="text-sm text-gray-400">-</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
` : emptyHtml;
this.shadowRoot.innerHTML = `
<style>
@import url('https://cdn.tailwindcss.com');
:host {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.table-row-hover:hover::before {
opacity: 1;
}
</style>
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden animate-slide-up">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50/50">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900">Notification Log</h2>
<div class="flex items-center gap-3">
<select id="channel-filter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 bg-white">
<option value="all" ${this.channelFilter === 'all' ? 'selected' : ''}>All Channels</option>
<option value="telegram" ${this.channelFilter === 'telegram' ? 'selected' : ''}>Telegram</option>
<option value="whatsapp" ${this.channelFilter === 'whatsapp' ? 'selected' : ''}>WhatsApp</option>
</select>
<select id="status-filter" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500 bg-white">
<option value="all" ${this.statusFilter === 'all' ? 'selected' : ''}>All Statuses</option>
<option value="sent" ${this.statusFilter === 'sent' ? 'selected' : ''}>Sent</option>
<option value="pending" ${this.statusFilter === 'pending' ? 'selected' : ''}>Pending</option>
<option value="failed" ${this.statusFilter === 'failed' ? 'selected' : ''}>Failed</option>
</select>
</div>
</div>
</div>
${this.isLoading ? loadingHtml : tableHtml}
</div>
`;
// Attach event listeners
if (!this.isLoading) {
this.shadowRoot.getElementById('channel-filter')?.addEventListener('change', (e) => this.onChannelChange(e.target.value));
this.shadowRoot.getElementById('status-filter')?.addEventListener('change', (e) => this.onStatusChange(e.target.value));
}
}
}
customElements.define('notification-log', NotificationLog);