feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
This commit is contained in:
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
export default function Navbar({ onMenuClick }) {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -51,6 +52,9 @@ export default function Navbar({ onMenuClick }) {
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Notification bell */}
|
||||
<NotificationBell />
|
||||
|
||||
{/* User dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
|
||||
167
src/components/NotificationBell.jsx
Normal file
167
src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Bell, BellOff, CheckCheck, ExternalLink } 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 } = 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 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"
|
||||
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>
|
||||
{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>
|
||||
)}
|
||||
</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={`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 flex-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>
|
||||
|
||||
{/* Unread dot + link indicator */}
|
||||
<div className="flex flex-col items-end gap-1 flex-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" />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays } from 'lucide-react';
|
||||
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
|
||||
import BrandLogo from './BrandLogo';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
@@ -10,6 +11,7 @@ import api from '../services/api';
|
||||
export default function Sidebar({ open, onClose }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { imprintUrl, privacyUrl } = useBranding();
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [federationCount, setFederationCount] = useState(0);
|
||||
|
||||
@@ -122,6 +124,37 @@ export default function Sidebar({ open, onClose }) {
|
||||
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Imprint / Privacy Policy links */}
|
||||
{(imprintUrl || privacyUrl) && (
|
||||
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
|
||||
{imprintUrl && (
|
||||
<a
|
||||
href={imprintUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<FileText size={11} />
|
||||
{t('nav.imprint')}
|
||||
</a>
|
||||
)}
|
||||
{imprintUrl && privacyUrl && (
|
||||
<span className="text-th-border text-xs">·</span>
|
||||
)}
|
||||
{privacyUrl && (
|
||||
<a
|
||||
href={privacyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||
>
|
||||
<Lock size={11} />
|
||||
{t('nav.privacy')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user