443 lines
21 KiB
HTML
443 lines
21 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Notifications — Nexus One AI</title>
|
||
<link rel="stylesheet" href="style.css?v=4">
|
||
<style>
|
||
.nt-layout { display: grid; grid-template-columns: 220px 1fr; min-height: calc(100vh - 64px); }
|
||
@media(max-width:700px){ .nt-layout { grid-template-columns: 1fr; } .nt-sidebar { display:none; } }
|
||
|
||
/* Sidebar */
|
||
.nt-sidebar {
|
||
border-right: 1px solid var(--bdr); background: var(--navy2);
|
||
padding: 20px 14px; display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.nt-sidebar-title { font-size: 11px; font-weight: 700; color: var(--lt); text-transform: uppercase; letter-spacing: .5px; padding: 0 8px 10px; }
|
||
.nt-filter-btn {
|
||
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
|
||
border-radius: 9px; cursor: pointer; border: none; background: none;
|
||
font-family: inherit; font-size: 13px; color: var(--med); text-align: left;
|
||
transition: .12s; width: 100%;
|
||
}
|
||
.nt-filter-btn:hover { background: rgba(124,58,237,.07); color: var(--ink); }
|
||
.nt-filter-btn.active { background: rgba(124,58,237,.12); color: var(--purple); font-weight: 700; }
|
||
.nt-filter-icon { font-size: 15px; width: 20px; text-align: center; }
|
||
.nt-filter-count {
|
||
margin-left: auto; background: var(--purple); color: white;
|
||
font-size: 10px; font-weight: 800; padding: 2px 7px;
|
||
border-radius: 20px; min-width: 20px; text-align: center;
|
||
}
|
||
.nt-filter-count.hidden { display: none; }
|
||
|
||
/* Main */
|
||
.nt-main { padding: 28px 32px; max-width: 860px; }
|
||
.nt-header { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; }
|
||
.nt-title { font-size: 22px; font-weight: 800; color: var(--ink); flex: 1; }
|
||
.nt-header-actions { display: flex; gap: 8px; }
|
||
.nt-btn {
|
||
padding: 8px 16px; border-radius: 9px; font-family: inherit;
|
||
font-size: 13px; font-weight: 600; cursor: pointer; transition: .15s;
|
||
border: 1.5px solid var(--bdr); background: none; color: var(--med);
|
||
}
|
||
.nt-btn:hover { border-color: var(--purple); color: var(--purple); }
|
||
.nt-btn.primary { background: var(--purple); color: white; border-color: var(--purple); }
|
||
.nt-btn.primary:hover { background: #6d28d9; }
|
||
.nt-btn.danger:hover { border-color: #ef4444; color: #ef4444; }
|
||
|
||
/* List */
|
||
.nt-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.nt-empty { text-align: center; padding: 60px 20px; color: var(--lt); }
|
||
.nt-empty-icon { font-size: 40px; margin-bottom: 12px; opacity: .35; }
|
||
|
||
.nt-item {
|
||
display: flex; gap: 14px; align-items: flex-start;
|
||
background: var(--navy2); border: 1.5px solid var(--bdr);
|
||
border-radius: 12px; padding: 14px 16px; cursor: pointer;
|
||
transition: .15s; position: relative;
|
||
}
|
||
.nt-item:hover { border-color: var(--purple); }
|
||
.nt-item.unread { border-left: 3px solid var(--purple); }
|
||
.nt-item.unread.sev-error { border-left-color: #ef4444; }
|
||
.nt-item.unread.sev-critical { border-left-color: #dc2626; }
|
||
.nt-item.unread.sev-warning { border-left-color: #f59e0b; }
|
||
|
||
.nt-item-icon {
|
||
width: 38px; height: 38px; border-radius: 10px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 18px; flex-shrink: 0;
|
||
}
|
||
.nt-item-icon.info { background: rgba(124,58,237,.12); }
|
||
.nt-item-icon.warning { background: rgba(245,158,11,.12); }
|
||
.nt-item-icon.error { background: rgba(239,68,68,.12); }
|
||
.nt-item-icon.critical { background: rgba(220,38,38,.15); }
|
||
|
||
.nt-item-body { flex: 1; min-width: 0; }
|
||
.nt-item-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; flex-wrap: wrap; }
|
||
.nt-item-title { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||
.nt-sev-badge {
|
||
font-size: 10px; font-weight: 800; text-transform: uppercase;
|
||
padding: 2px 8px; border-radius: 20px; letter-spacing: .3px;
|
||
}
|
||
.nt-sev-badge.info { background: rgba(124,58,237,.1); color: var(--purple); }
|
||
.nt-sev-badge.warning { background: rgba(245,158,11,.12); color: #d97706; }
|
||
.nt-sev-badge.error { background: rgba(239,68,68,.1); color: #dc2626; }
|
||
.nt-sev-badge.critical { background: rgba(220,38,38,.15); color: #b91c1c; }
|
||
.nt-src-badge {
|
||
font-size: 10px; font-weight: 700; padding: 2px 8px;
|
||
border-radius: 20px; background: rgba(100,116,139,.1); color: var(--med);
|
||
}
|
||
.nt-item-time { margin-left: auto; font-size: 11px; color: var(--lt); white-space: nowrap; }
|
||
.nt-item-body-text { font-size: 13px; color: var(--med); line-height: 1.55; }
|
||
.nt-item-link { font-size: 12px; color: var(--purple); margin-top: 5px; display: inline-block; }
|
||
|
||
.nt-item-actions { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
|
||
.nt-icon-btn {
|
||
background: none; border: none; cursor: pointer; padding: 4px 6px;
|
||
border-radius: 6px; color: var(--lt); font-size: 13px; transition: .12s;
|
||
}
|
||
.nt-icon-btn:hover { background: rgba(124,58,237,.1); color: var(--purple); }
|
||
.nt-icon-btn.del:hover { background: rgba(239,68,68,.1); color: #ef4444; }
|
||
|
||
.nt-unread-dot {
|
||
position: absolute; top: 14px; right: 14px;
|
||
width: 8px; height: 8px; border-radius: 50%; background: var(--purple);
|
||
}
|
||
.nt-unread-dot.hidden { display: none; }
|
||
|
||
/* Stats row */
|
||
.nt-stats { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||
.nt-stat-card {
|
||
background: var(--navy2); border: 1.5px solid var(--bdr);
|
||
border-radius: 11px; padding: 12px 18px; min-width: 110px;
|
||
flex: 1;
|
||
}
|
||
.nt-stat-label { font-size: 11px; font-weight: 700; color: var(--lt); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 4px; }
|
||
.nt-stat-val { font-size: 24px; font-weight: 800; color: var(--ink); }
|
||
.nt-stat-val.purple { color: var(--purple); }
|
||
.nt-stat-val.orange { color: #d97706; }
|
||
.nt-stat-val.red { color: #dc2626; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topnav">
|
||
<a href="index.html" class="brand">Nexus One <span>AI</span></a>
|
||
<nav>
|
||
<a href="index.html">Home</a>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="prompts.html">Prompt Library</a>
|
||
<a href="usecases.html">Use Cases</a>
|
||
<span class="nav-sep"></span>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">Help ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">LEARN /</span>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="models.html">Models</a>
|
||
<span class="nav-drop-cat">SUPPORT /</span>
|
||
<a href="troubleshooting.html">Troubleshoot</a>
|
||
<a href="faq.html">FAQ</a>
|
||
<span class="nav-drop-cat">MORE /</span>
|
||
<a href="glossary.html">Glossary</a>
|
||
<a href="whats-new.html">What's New</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn active">Admin ▾</button>
|
||
<div class="nav-drop-menu nav-drop-menu-wide">
|
||
<span class="nav-drop-cat">DOCS /</span>
|
||
<a href="security.html">Security & Privacy</a>
|
||
<a href="admin.html">Admin Guide</a>
|
||
<span class="nav-drop-cat">MONITOR /</span>
|
||
<a href="dashboard.html">Dashboard</a>
|
||
<a href="analytics.html">Usage Analytics</a>
|
||
<a href="audit.html">Audit Log</a>
|
||
<a href="feedback.html">Feedback & Ratings</a>
|
||
<span class="nav-drop-cat">MANAGE /</span>
|
||
<a href="users.html">Users</a>
|
||
<a href="teams.html">Teams</a>
|
||
<a href="models-admin.html">Model Manager</a>
|
||
<a href="training.html">Training</a>
|
||
<a href="knowledge.html">Knowledge Base</a>
|
||
<span class="nav-drop-cat">TOOLS /</span>
|
||
<a href="apikeys.html">API Keys</a>
|
||
<a href="benchmark.html">Benchmarking</a>
|
||
<a href="model-compare.html">Model Compare</a>
|
||
<a href="api-playground.html">API Playground</a>
|
||
<a href="guardrails.html">Guardrails</a>
|
||
<a href="rag-quality.html">RAG Quality</a>
|
||
<a href="router.html">Model Router</a>
|
||
<a href="connectors.html">Connectors</a>
|
||
<span class="nav-drop-cat">SYSTEM /</span>
|
||
<a href="console.html">Console</a>
|
||
<a href="settings.html">Settings</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">AI Tools ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">INTELLIGENCE /</span>
|
||
<a href="documents.html">Document Intelligence</a>
|
||
<a href="chat-multi.html">Multimodal Chat</a>
|
||
<a href="prompt-studio.html">Prompt Studio</a>
|
||
<a href="meeting.html">Meeting Assistant</a>
|
||
<span class="nav-drop-cat">AUTOMATION /</span>
|
||
<a href="agents.html">Agent Builder</a>
|
||
<a href="schedules.html">Scheduled Jobs</a>
|
||
<a href="workflows.html">Workflow Automation</a>
|
||
<span class="nav-drop-cat">QUALITY /</span>
|
||
<a href="evals.html">AI Eval Suite</a>
|
||
<a href="chatrooms.html">Chat Rooms</a>
|
||
</div>
|
||
</div>
|
||
<a href="notifications.html" class="active" style="position:relative">
|
||
🔔 <span id="nav-badge" class="nt-filter-count hidden" style="position:absolute;top:-4px;right:-8px;font-size:9px;padding:1px 5px"></span>
|
||
</a>
|
||
</nav>
|
||
<span class="badge" data-brand="tier">Basic Tier</span>
|
||
<div id="nav-org-logo" class="nav-org-logo"></div>
|
||
</header>
|
||
|
||
<div class="nt-layout">
|
||
|
||
<!-- Sidebar -->
|
||
<div class="nt-sidebar">
|
||
<div class="nt-sidebar-title">Filter</div>
|
||
<button class="nt-filter-btn active" onclick="setFilter('all', this)">
|
||
<span class="nt-filter-icon">📬</span> All
|
||
<span class="nt-filter-count hidden" id="cnt-all"></span>
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('unread', this)">
|
||
<span class="nt-filter-icon">🔵</span> Unread
|
||
<span class="nt-filter-count hidden" id="cnt-unread"></span>
|
||
</button>
|
||
|
||
<div style="margin: 14px 8px 6px; font-size:11px;font-weight:700;color:var(--lt);text-transform:uppercase;letter-spacing:.4px">By Source</div>
|
||
<button class="nt-filter-btn" onclick="setFilter('guardrail', this)">
|
||
<span class="nt-filter-icon">🛡</span> Guardrails
|
||
<span class="nt-filter-count hidden" id="cnt-guardrail"></span>
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('scheduler', this)">
|
||
<span class="nt-filter-icon">⏱</span> Scheduler
|
||
<span class="nt-filter-count hidden" id="cnt-scheduler"></span>
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('agent', this)">
|
||
<span class="nt-filter-icon">🤖</span> Agents
|
||
<span class="nt-filter-count hidden" id="cnt-agent"></span>
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('rag', this)">
|
||
<span class="nt-filter-icon">📚</span> RAG / KB
|
||
<span class="nt-filter-count hidden" id="cnt-rag"></span>
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('system', this)">
|
||
<span class="nt-filter-icon">⚙️</span> System
|
||
<span class="nt-filter-count hidden" id="cnt-system"></span>
|
||
</button>
|
||
|
||
<div style="margin: 14px 8px 6px; font-size:11px;font-weight:700;color:var(--lt);text-transform:uppercase;letter-spacing:.4px">By Severity</div>
|
||
<button class="nt-filter-btn" onclick="setFilter('sev-critical', this)">
|
||
<span class="nt-filter-icon">🔴</span> Critical
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('sev-error', this)">
|
||
<span class="nt-filter-icon">🟠</span> Error
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('sev-warning', this)">
|
||
<span class="nt-filter-icon">🟡</span> Warning
|
||
</button>
|
||
<button class="nt-filter-btn" onclick="setFilter('sev-info', this)">
|
||
<span class="nt-filter-icon">🔵</span> Info
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Main -->
|
||
<div class="nt-main">
|
||
<div class="nt-header">
|
||
<div class="nt-title">🔔 Notifications</div>
|
||
<div class="nt-header-actions">
|
||
<button class="nt-btn" onclick="markAllRead()">✓ Mark all read</button>
|
||
<button class="nt-btn danger" onclick="clearAll()">🗑 Clear all</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats -->
|
||
<div class="nt-stats">
|
||
<div class="nt-stat-card">
|
||
<div class="nt-stat-label">Total</div>
|
||
<div class="nt-stat-val purple" id="stat-total">—</div>
|
||
</div>
|
||
<div class="nt-stat-card">
|
||
<div class="nt-stat-label">Unread</div>
|
||
<div class="nt-stat-val purple" id="stat-unread">—</div>
|
||
</div>
|
||
<div class="nt-stat-card">
|
||
<div class="nt-stat-label">Errors</div>
|
||
<div class="nt-stat-val red" id="stat-errors">—</div>
|
||
</div>
|
||
<div class="nt-stat-card">
|
||
<div class="nt-stat-label">Warnings</div>
|
||
<div class="nt-stat-val orange" id="stat-warnings">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nt-list" id="nt-list">
|
||
<div class="nt-empty"><div class="nt-empty-icon">🔔</div><p>Loading…</p></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const SOURCE_ICONS = { guardrail:'🛡', scheduler:'⏱', agent:'🤖', rag:'📚', system:'⚙️' };
|
||
const SEV_ICONS = { info:'ℹ️', warning:'⚠️', error:'❌', critical:'🚨' };
|
||
|
||
let allNotifications = [];
|
||
let currentFilter = 'all';
|
||
|
||
const MOCK = [
|
||
{ id:1, title:'Guardrail triggered — PII detected', body:'User "ravi.k" attempted to upload a file containing Aadhaar numbers. The upload was blocked by the PII guardrail rule.', source:'guardrail', severity:'error', link:'guardrails.html', is_read:0, created_at: new Date(Date.now()-3*60000).toISOString() },
|
||
{ id:2, title:'Scheduled job failed — Nightly Audit Report', body:'Job "Nightly Audit Report" (ID 7) failed at 02:00 with error: Connection timeout to PostgreSQL connector. Last successful run was 2 days ago.', source:'scheduler', severity:'error', link:'schedules.html', is_read:0, created_at: new Date(Date.now()-2*60*60000).toISOString() },
|
||
{ id:3, title:'Agent run error — Contract Monitor', body:'Agent "Contract Expiry Monitor" encountered an error on run #42: Unable to parse PDF — file appears to be scanned without OCR layer.', source:'agent', severity:'warning', link:'agents.html', is_read:0, created_at: new Date(Date.now()-5*60*60000).toISOString() },
|
||
{ id:4, title:'RAG ingestion failed — Q3 Policy Docs', body:'Ingestion job for collection "Q3 Policy Docs" failed on 3 files. Reason: Unsupported file type (.pptx). Re-upload as PDF to proceed.', source:'rag', severity:'warning', link:'knowledge.html', is_read:0, created_at: new Date(Date.now()-8*60*60000).toISOString() },
|
||
{ id:5, title:'New user registered — awaiting approval', body:'User "priya.nair@iimb.ac.in" has registered and is pending admin approval.', source:'system', severity:'info', link:'users.html', is_read:1, created_at: new Date(Date.now()-1*24*60*60000).toISOString() },
|
||
{ id:6, title:'Model "llama3.1:70b" download complete', body:'Model llama3.1:70b (40.2 GB) has finished downloading and is ready for use in the Model Manager.', source:'system', severity:'info', link:'models-admin.html', is_read:1, created_at: new Date(Date.now()-2*24*60*60000).toISOString() },
|
||
{ id:7, title:'Guardrail triggered — prompt injection attempt', body:'A potential prompt injection was detected in chat session from user "external01". The message was flagged and not sent to the model.', source:'guardrail', severity:'critical', link:'guardrails.html', is_read:1, created_at: new Date(Date.now()-3*24*60*60000).toISOString() },
|
||
{ id:8, title:'RAG quality score dropped below threshold', body:'Collection "Legal Documents" average retrieval score dropped to 0.42 (threshold: 0.60). Consider re-indexing or adding more documents.', source:'rag', severity:'warning', link:'rag-quality.html', is_read:1, created_at: new Date(Date.now()-4*24*60*60000).toISOString() },
|
||
];
|
||
|
||
async function loadNotifications() {
|
||
try {
|
||
const r = await fetch('/api/notifications?limit=100');
|
||
if (!r.ok) throw new Error();
|
||
const d = await r.json();
|
||
allNotifications = d.notifications;
|
||
updateStats(d.unread_count);
|
||
} catch {
|
||
allNotifications = MOCK;
|
||
updateStats(MOCK.filter(n => !n.is_read).length);
|
||
}
|
||
render();
|
||
}
|
||
|
||
function updateStats(unreadCount) {
|
||
document.getElementById('stat-total').textContent = allNotifications.length;
|
||
document.getElementById('stat-unread').textContent = unreadCount;
|
||
document.getElementById('stat-errors').textContent = allNotifications.filter(n => n.severity === 'error' || n.severity === 'critical').length;
|
||
document.getElementById('stat-warnings').textContent = allNotifications.filter(n => n.severity === 'warning').length;
|
||
|
||
// Sidebar counts
|
||
document.getElementById('cnt-all').textContent = allNotifications.length;
|
||
document.getElementById('cnt-all').classList.toggle('hidden', !allNotifications.length);
|
||
document.getElementById('cnt-unread').textContent = unreadCount;
|
||
document.getElementById('cnt-unread').classList.toggle('hidden', !unreadCount);
|
||
|
||
for (const src of ['guardrail','scheduler','agent','rag','system']) {
|
||
const n = allNotifications.filter(x => x.source === src).length;
|
||
document.getElementById(`cnt-${src}`).textContent = n;
|
||
document.getElementById(`cnt-${src}`).classList.toggle('hidden', !n);
|
||
}
|
||
|
||
// Nav badge
|
||
const badge = document.getElementById('nav-badge');
|
||
if (unreadCount > 0) {
|
||
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
||
badge.classList.remove('hidden');
|
||
} else {
|
||
badge.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function setFilter(f, btn) {
|
||
currentFilter = f;
|
||
document.querySelectorAll('.nt-filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
render();
|
||
}
|
||
|
||
function getFiltered() {
|
||
if (currentFilter === 'all') return allNotifications;
|
||
if (currentFilter === 'unread') return allNotifications.filter(n => !n.is_read);
|
||
if (currentFilter.startsWith('sev-')) return allNotifications.filter(n => n.severity === currentFilter.slice(4));
|
||
return allNotifications.filter(n => n.source === currentFilter);
|
||
}
|
||
|
||
function timeAgo(iso) {
|
||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||
if (diff < 3600) return `${Math.floor(diff/60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;
|
||
return `${Math.floor(diff/86400)}d ago`;
|
||
}
|
||
|
||
function render() {
|
||
const list = document.getElementById('nt-list');
|
||
const items = getFiltered();
|
||
if (!items.length) {
|
||
list.innerHTML = `<div class="nt-empty"><div class="nt-empty-icon">✅</div><p>No notifications here.</p></div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = items.map(n => `
|
||
<div class="nt-item ${n.is_read ? '' : 'unread'} sev-${n.severity}" onclick="markRead(${n.id})" id="nt-${n.id}">
|
||
<div class="nt-item-icon ${n.severity}">${SEV_ICONS[n.severity] || '📌'}</div>
|
||
<div class="nt-item-body">
|
||
<div class="nt-item-header">
|
||
<span class="nt-item-title">${n.title}</span>
|
||
<span class="nt-sev-badge ${n.severity}">${n.severity}</span>
|
||
<span class="nt-src-badge">${SOURCE_ICONS[n.source]||''} ${n.source}</span>
|
||
<span class="nt-item-time">${timeAgo(n.created_at)}</span>
|
||
</div>
|
||
<div class="nt-item-body-text">${n.body}</div>
|
||
${n.link ? `<a class="nt-item-link" href="${n.link}" onclick="event.stopPropagation()">→ View details</a>` : ''}
|
||
</div>
|
||
<div class="nt-item-actions">
|
||
<button class="nt-icon-btn" onclick="event.stopPropagation();markRead(${n.id})" title="Mark read">✓</button>
|
||
<button class="nt-icon-btn del" onclick="event.stopPropagation();deleteNotif(${n.id})" title="Delete">✕</button>
|
||
</div>
|
||
<div class="nt-unread-dot ${n.is_read ? 'hidden' : ''}"></div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function markRead(id) {
|
||
const n = allNotifications.find(x => x.id === id);
|
||
if (!n || n.is_read) return;
|
||
n.is_read = 1;
|
||
try { await fetch(`/api/notifications/${id}/read`, { method:'PATCH' }); } catch {}
|
||
const unread = allNotifications.filter(x => !x.is_read).length;
|
||
updateStats(unread);
|
||
render();
|
||
}
|
||
|
||
async function markAllRead() {
|
||
allNotifications.forEach(n => n.is_read = 1);
|
||
try { await fetch('/api/notifications/read-all', { method:'POST' }); } catch {}
|
||
updateStats(0);
|
||
render();
|
||
}
|
||
|
||
async function deleteNotif(id) {
|
||
allNotifications = allNotifications.filter(x => x.id !== id);
|
||
try { await fetch(`/api/notifications/${id}`, { method:'DELETE' }); } catch {}
|
||
const unread = allNotifications.filter(x => !x.is_read).length;
|
||
updateStats(unread);
|
||
render();
|
||
}
|
||
|
||
async function clearAll() {
|
||
if (!confirm('Clear all notifications?')) return;
|
||
allNotifications = [];
|
||
try { await fetch('/api/notifications', { method:'DELETE' }); } catch {}
|
||
updateStats(0);
|
||
render();
|
||
}
|
||
|
||
loadNotifications();
|
||
// Poll every 30s for new notifications
|
||
setInterval(loadNotifications, 30000);
|
||
</script>
|
||
|
||
<script src="auth.js"></script>
|
||
<script src="branding.js"></script>
|
||
</body>
|
||
</html>
|