morozovka-portal/app/static/notif_bell.js

132 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function() {
'use strict';
if (window.__notifBellLoaded) return;
window.__notifBellLoaded = true;
const style = document.createElement('style');
style.textContent = `
.notif-bell { position: fixed; top: 12px; right: 32px; z-index: 1000; }
.notif-bell-btn { position: relative; background: #21262d; border: 1px solid #30363d; border-radius: 6px; width: 32px; height: 32px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #c9d1d9; padding: 0; }
.notif-bell-btn:hover { background: #30363d; }
.notif-bell-badge { position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; background: #f85149; color: #fff; border-radius: 10px; font-size: 11px; font-weight: 600; display: none; align-items: center; justify-content: center; }
.notif-bell-badge.visible { display: inline-flex; }
.notif-dropdown { position: absolute; top: 40px; right: 0; width: 360px; max-height: 500px; overflow-y: auto; background: #161b22; border: 1px solid #30363d; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; }
.notif-dropdown.open { display: block; }
.notif-header { padding: 12px 16px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.notif-header h4 { margin: 0; font-size: 14px; font-weight: 600; color: #e6edf3; }
.notif-mark-all { color: #58a6ff; background: none; border: none; cursor: pointer; font-size: 12px; padding: 0; }
.notif-mark-all:hover { text-decoration: underline; }
.notif-list { padding: 0; }
.notif-item { display: block; padding: 12px 16px 12px 22px; border-bottom: 1px solid #21262d; color: inherit; cursor: pointer; text-decoration: none; transition: background 0.15s; position: relative; }
.notif-item:hover { background: #1c2128; text-decoration: none; }
.notif-item.unread { background: rgba(88,166,255,0.05); }
.notif-item.unread::before { content: ''; position: absolute; left: 8px; top: 18px; width: 6px; height: 6px; background: #58a6ff; border-radius: 50%; }
.notif-item-title { color: #e6edf3; font-weight: 500; font-size: 13px; margin-bottom: 2px; }
.notif-item-body { color: #8b949e; font-size: 12px; line-height: 1.4; margin-bottom: 4px; }
.notif-item-time { color: #6e7681; font-size: 11px; }
.notif-item.kind-success .notif-item-title { color: #3fb950; }
.notif-item.kind-warning .notif-item-title { color: #d29922; }
.notif-item.kind-error .notif-item-title { color: #f85149; }
.notif-empty { padding: 32px 16px; text-align: center; color: #8b949e; font-size: 13px; }
.notif-footer { padding: 8px 16px; border-top: 1px solid #30363d; text-align: center; }
.notif-footer a { color: #58a6ff; font-size: 12px; }
@media (max-width: 768px) {
.notif-bell { top: 8px; right: 16px; }
.notif-dropdown { width: calc(100vw - 32px); right: -8px; }
}`;
document.head.appendChild(style);
const container = document.createElement('div');
container.className = 'notif-bell';
container.innerHTML = `
<button class="notif-bell-btn" id="notif-bell-btn" title="Уведомления" aria-label="Уведомления">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01c0 .01.006.014.015.014h11.972a.017.017 0 0 0 .015-.014.017.017 0 0 0-.003-.01l-1.703-2.556a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z"/></svg>
<span class="notif-bell-badge" id="notif-bell-badge"></span>
</button>
<div class="notif-dropdown" id="notif-dropdown">
<div class="notif-header">
<h4>Уведомления</h4>
<button class="notif-mark-all" id="notif-mark-all">Прочитать все</button>
</div>
<div class="notif-list" id="notif-list">
<div class="notif-empty">Загрузка...</div>
</div>
<div class="notif-footer"><a href="/notifications">Все уведомления →</a></div>
</div>`;
document.body.appendChild(container);
const btn = document.getElementById('notif-bell-btn');
const badge = document.getElementById('notif-bell-badge');
const dropdown = document.getElementById('notif-dropdown');
const list = document.getElementById('notif-list');
const markAll = document.getElementById('notif-mark-all');
function fmtTime(iso) {
const d = new Date(iso);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'только что';
if (diff < 3600) return Math.floor(diff/60) + ' мин назад';
if (diff < 86400) return Math.floor(diff/3600) + ' ч назад';
return d.toLocaleDateString('ru-RU');
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function render(data) {
badge.textContent = data.unread;
badge.classList.toggle('visible', data.unread > 0);
if (!data.items || data.items.length === 0) {
list.innerHTML = '<div class="notif-empty">Нет уведомлений</div>';
return;
}
list.innerHTML = data.items.map(n => `
<a class="notif-item kind-${n.kind}${n.is_read ? '' : ' unread'}" data-id="${n.id}" data-link="${n.link_url || ''}" href="${n.link_url || '#'}">
<div class="notif-item-title">${escapeHtml(n.title)}</div>
${n.body ? `<div class="notif-item-body">${escapeHtml(n.body)}</div>` : ''}
<div class="notif-item-time">${fmtTime(n.created_at)}</div>
</a>
`).join('');
list.querySelectorAll('.notif-item').forEach(el => {
el.addEventListener('click', async (e) => {
const id = el.dataset.id;
const link = el.dataset.link;
e.preventDefault();
try { await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch (err) {}
if (link) window.location.href = link;
else { el.classList.remove('unread'); refresh(); }
});
});
}
async function refresh() {
try {
const r = await fetch('/api/notifications', { credentials: 'same-origin' });
if (!r.ok) return;
const data = await r.json();
render(data);
} catch (e) {}
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
if (dropdown.classList.contains('open')) refresh();
});
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) dropdown.classList.remove('open');
});
markAll.addEventListener('click', async (e) => {
e.stopPropagation();
try { await fetch('/api/notifications/read-all', { method: 'POST' }); } catch (err) {}
refresh();
});
refresh();
setInterval(refresh, 30000);
})();