feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s

This commit is contained in:
2026-03-02 16:45:53 +01:00
parent 304349fce8
commit c13090bc80
16 changed files with 626 additions and 20 deletions

View File

@@ -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

View 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>
);
}

View File

@@ -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>

View File

@@ -11,6 +11,8 @@ export function BrandingProvider({ children }) {
hasLogo: false,
logoUrl: null,
defaultTheme: null,
imprintUrl: null,
privacyUrl: null,
});
const fetchBranding = useCallback(async () => {

View File

@@ -0,0 +1,94 @@
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { useAuth } from './AuthContext';
import api from '../services/api';
const NotificationContext = createContext();
export function NotificationProvider({ children }) {
const { user } = useAuth();
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
// Track seen IDs to detect genuinely new arrivals and show toasts
const seenIds = useRef(new Set());
const initialized = useRef(false);
const fetch = useCallback(async () => {
if (!user) return;
try {
const res = await api.get('/notifications');
const incoming = res.data.notifications || [];
setNotifications(incoming);
setUnreadCount(res.data.unreadCount || 0);
// First fetch: just seed the seen-set without toasting
if (!initialized.current) {
incoming.forEach(n => seenIds.current.add(n.id));
initialized.current = true;
return;
}
// Subsequent fetches: toast new unread notifications
const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id));
newItems.forEach(n => {
seenIds.current.add(n.id);
const icon = notificationIcon(n.type);
toast(`${icon} ${n.title}`, { duration: 5000 });
});
} catch {
/* silent server may not be reachable */
}
}, [user]);
useEffect(() => {
if (!user) {
setNotifications([]);
setUnreadCount(0);
seenIds.current = new Set();
initialized.current = false;
return;
}
fetch();
const interval = setInterval(fetch, 30_000);
return () => clearInterval(interval);
}, [user, fetch]);
const markRead = async (id) => {
try {
await api.post(`/notifications/${id}/read`);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: 1 } : n)),
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch { /* silent */ }
};
const markAllRead = async () => {
try {
await api.post('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, read: 1 })));
setUnreadCount(0);
} catch { /* silent */ }
};
return (
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, refresh: fetch }}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
return ctx;
}
function notificationIcon(type) {
switch (type) {
case 'room_share_added': return '🔗';
case 'room_share_removed': return '🚫';
case 'federation_invite_received': return '📩';
default: return '🔔';
}
}

View File

@@ -33,7 +33,9 @@
"changeTheme": "Theme ändern",
"navigation": "Navigation",
"calendar": "Kalender",
"federation": "Einladungen"
"federation": "Einladungen",
"imprint": "Impressum",
"privacy": "Datenschutz"
},
"auth": {
"login": "Anmelden",
@@ -356,7 +358,23 @@
"inviteExpired": "Abgelaufen",
"inviteUsedBy": "Verwendet von",
"inviteExpiresAt": "Läuft ab am",
"noInvites": "Noch keine Einladungen"
"noInvites": "Noch keine Einladungen",
"legalLinksTitle": "Rechtliche Links",
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
"imprintUrl": "Impressum-URL",
"privacyUrl": "Datenschutz-URL",
"imprintUrlSaved": "Impressum-URL gespeichert",
"privacyUrlSaved": "Datenschutz-URL gespeichert",
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden"
},
"notifications": {
"bell": "Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"noNotifications": "Keine Benachrichtigungen",
"roomShareAdded": "Raum wurde mit dir geteilt",
"roomShareRemoved": "Raumzugriff wurde entfernt",
"federationInviteReceived": "Neue Meeting-Einladung"
},
"federation": {
"inbox": "Einladungen",

View File

@@ -33,7 +33,9 @@
"changeTheme": "Change theme",
"navigation": "Navigation",
"calendar": "Calendar",
"federation": "Invitations"
"federation": "Invitations",
"imprint": "Imprint",
"privacy": "Privacy Policy"
},
"auth": {
"login": "Sign in",
@@ -356,7 +358,23 @@
"inviteExpired": "Expired",
"inviteUsedBy": "Used by",
"inviteExpiresAt": "Expires",
"noInvites": "No invitations yet"
"noInvites": "No invitations yet",
"legalLinksTitle": "Legal Links",
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
"imprintUrl": "Imprint URL",
"privacyUrl": "Privacy Policy URL",
"imprintUrlSaved": "Imprint URL saved",
"privacyUrlSaved": "Privacy Policy URL saved",
"imprintUrlFailed": "Could not save Imprint URL",
"privacyUrlFailed": "Could not save Privacy Policy URL"
},
"notifications": {
"bell": "Notifications",
"markAllRead": "Mark all as read",
"noNotifications": "No notifications yet",
"roomShareAdded": "Room shared with you",
"roomShareRemoved": "Room access removed",
"federationInviteReceived": "New meeting invitation"
},
"federation": {
"inbox": "Invitations",

View File

@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrandingProvider } from './contexts/BrandingContext';
import { NotificationProvider } from './contexts/NotificationContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
@@ -16,18 +17,20 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<ThemeProvider>
<BrandingProvider>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
<NotificationProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
</NotificationProvider>
</AuthProvider>
</BrandingProvider>
</ThemeProvider>

View File

@@ -4,7 +4,7 @@ import {
Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
ShieldCheck, Globe,
ShieldCheck, Globe, Link as LinkIcon,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
export default function Admin() {
const { user } = useAuth();
const { t, language } = useLanguage();
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, refreshBranding } = useBranding();
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding();
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -43,6 +43,10 @@ export default function Admin() {
const logoInputRef = useRef(null);
const [editDefaultTheme, setEditDefaultTheme] = useState('');
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
const [editImprintUrl, setEditImprintUrl] = useState('');
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
useEffect(() => {
if (user?.role !== 'admin') {
@@ -61,6 +65,14 @@ export default function Admin() {
setEditDefaultTheme(defaultTheme || 'dark');
}, [defaultTheme]);
useEffect(() => {
setEditImprintUrl(imprintUrl || '');
}, [imprintUrl]);
useEffect(() => {
setEditPrivacyUrl(privacyUrl || '');
}, [privacyUrl]);
const fetchUsers = async () => {
try {
const res = await api.get('/admin/users');
@@ -237,6 +249,32 @@ export default function Admin() {
}
};
const handleImprintUrlSave = async () => {
setSavingImprintUrl(true);
try {
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
toast.success(t('admin.imprintUrlSaved'));
refreshBranding();
} catch {
toast.error(t('admin.imprintUrlFailed'));
} finally {
setSavingImprintUrl(false);
}
};
const handlePrivacyUrlSave = async () => {
setSavingPrivacyUrl(true);
try {
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
toast.success(t('admin.privacyUrlSaved'));
refreshBranding();
} catch {
toast.error(t('admin.privacyUrlFailed'));
} finally {
setSavingPrivacyUrl(false);
}
};
const filteredUsers = users.filter(u =>
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
@@ -381,6 +419,59 @@ export default function Admin() {
</button>
</div>
</div>
{/* Legal links */}
<div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1">
<LinkIcon size={16} className="text-th-accent" />
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
</div>
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
<div className="grid gap-4 sm:grid-cols-2">
{/* Imprint */}
<div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
<div className="flex items-center gap-2">
<input
type="url"
value={editImprintUrl}
onChange={e => setEditImprintUrl(e.target.value)}
className="input-field text-sm flex-1"
placeholder="https://example.com/imprint"
maxLength={500}
/>
<button
onClick={handleImprintUrlSave}
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
{/* Privacy Policy */}
<div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
<div className="flex items-center gap-2">
<input
type="url"
value={editPrivacyUrl}
onChange={e => setEditPrivacyUrl(e.target.value)}
className="input-field text-sm flex-1"
placeholder="https://example.com/privacy"
maxLength={500}
/>
<button
onClick={handlePrivacyUrlSave}
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
</div>
</div>
</div>
{/* Registration Mode */}