Build & Push Docker Image / build (push) Successful in 4m4s
- Added `htmlFor` attributes to labels for better accessibility in Calendar, Dashboard, GuestJoin, Login, Register, RoomDetail, and Settings pages. - Included `aria-hidden` attributes for icons to improve screen reader experience. - Set `autoComplete` attributes for input fields to enhance user experience during form filling. - Implemented `role` and `aria` attributes for radio groups and buttons to improve accessibility compliance.
198 lines
7.5 KiB
React
198 lines
7.5 KiB
React
import { useRef, useState, useEffect } from 'react';
|
|
import { Bell, BellOff, CheckCheck, ExternalLink, Trash2, X } from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useNotifications } from '../contexts/NotificationContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
|
|
function timeAgo(dateStr, lang) {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const m = Math.floor(diff / 60_000);
|
|
const h = Math.floor(diff / 3_600_000);
|
|
const d = Math.floor(diff / 86_400_000);
|
|
if (lang === 'de') {
|
|
if (m < 1) return 'gerade eben';
|
|
if (m < 60) return `vor ${m} Min.`;
|
|
if (h < 24) return `vor ${h} Std.`;
|
|
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
|
}
|
|
if (m < 1) return 'just now';
|
|
if (m < 60) return `${m}m ago`;
|
|
if (h < 24) return `${h}h ago`;
|
|
return `${d}d ago`;
|
|
}
|
|
|
|
function notificationIcon(type) {
|
|
switch (type) {
|
|
case 'room_share_added': return '🔗';
|
|
case 'room_share_removed': return '🚫';
|
|
case 'federation_invite_received': return '📩';
|
|
default: return '🔔';
|
|
}
|
|
}
|
|
|
|
function notificationSubtitle(n, t, lang) {
|
|
switch (n.type) {
|
|
case 'room_share_added':
|
|
return n.body
|
|
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
|
|
: t('notifications.roomShareAdded');
|
|
case 'room_share_removed':
|
|
return t('notifications.roomShareRemoved');
|
|
case 'federation_invite_received':
|
|
return n.body
|
|
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
|
|
: t('notifications.federationInviteReceived');
|
|
default:
|
|
return n.body || '';
|
|
}
|
|
}
|
|
|
|
export default function NotificationBell() {
|
|
const { notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll } = useNotifications();
|
|
const { t, language } = useLanguage();
|
|
const navigate = useNavigate();
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
function handleOutsideClick(e) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleOutsideClick);
|
|
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
|
}, []);
|
|
|
|
const handleNotificationClick = async (n) => {
|
|
if (!n.read) await markRead(n.id);
|
|
if (n.link) navigate(n.link);
|
|
setOpen(false);
|
|
};
|
|
|
|
const handleDelete = async (e, id) => {
|
|
e.stopPropagation();
|
|
await deleteNotification(id);
|
|
};
|
|
|
|
const recent = notifications.slice(0, 20);
|
|
|
|
return (
|
|
<div className="relative" ref={containerRef}>
|
|
{/* Bell button */}
|
|
<button
|
|
onClick={() => setOpen(prev => !prev)}
|
|
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
|
aria-label={unreadCount > 0
|
|
? `${t('notifications.bell')} (${unreadCount})`
|
|
: t('notifications.bell')}
|
|
aria-haspopup="true"
|
|
aria-expanded={open}
|
|
title={t('notifications.bell')}
|
|
>
|
|
<Bell size={20} />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
{open && (
|
|
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
|
|
<div className="flex items-center gap-2">
|
|
<Bell size={16} className="text-th-accent" />
|
|
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
|
|
{unreadCount > 0 && (
|
|
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
|
{unreadCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={markAllRead}
|
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
|
title={t('notifications.markAllRead')}
|
|
>
|
|
<CheckCheck size={14} />
|
|
{t('notifications.markAllRead')}
|
|
</button>
|
|
)}
|
|
{notifications.length > 0 && (
|
|
<button
|
|
onClick={clearAll}
|
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-error transition-colors"
|
|
title={t('notifications.clearAll')}
|
|
>
|
|
<Trash2 size={13} />
|
|
{t('notifications.clearAll')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{recent.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
|
|
<BellOff size={24} />
|
|
<span className="text-sm">{t('notifications.noNotifications')}</span>
|
|
</div>
|
|
) : (
|
|
<ul>
|
|
{recent.map(n => (
|
|
<li
|
|
key={n.id}
|
|
onClick={() => handleNotificationClick(n)}
|
|
className={`group flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
|
|
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
|
>
|
|
{/* Icon */}
|
|
<span className="text-lg shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
|
|
{n.title}
|
|
</p>
|
|
<p className="text-xs text-th-text-s truncate">
|
|
{notificationSubtitle(n, t, language)}
|
|
</p>
|
|
<p className="text-xs text-th-text-s/70 mt-0.5">
|
|
{timeAgo(n.created_at, language)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Right side: unread dot, link icon, delete button */}
|
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
|
{!n.read && (
|
|
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
|
)}
|
|
{n.link && (
|
|
<ExternalLink size={12} className="text-th-text-s/50" />
|
|
)}
|
|
<button
|
|
onClick={(e) => handleDelete(e, n.id)}
|
|
className="opacity-0 group-hover:opacity-100 p-0.5 rounded-sm hover:text-th-error transition-all text-th-text-s/50"
|
|
aria-label={t('notifications.delete')}
|
|
title={t('notifications.delete')}
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|