feat(notifications): add delete functionality for individual and all notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
feat(guest-join): support access code in guest join URL
This commit is contained in:
@@ -83,8 +83,11 @@ router.post('/invite', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build guest join URL for the remote user
|
// Build guest join URL for the remote user
|
||||||
|
// If the room has an access code, embed it so the recipient can join without manual entry
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
const joinUrl = `${baseUrl}/join/${room.uid}`;
|
const joinUrl = room.access_code
|
||||||
|
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
||||||
|
: `${baseUrl}/join/${room.uid}`;
|
||||||
|
|
||||||
// Build invitation payload
|
// Build invitation payload
|
||||||
const inviteId = uuidv4();
|
const inviteId = uuidv4();
|
||||||
|
|||||||
@@ -45,4 +45,30 @@ router.post('/:id/read', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DELETE /api/notifications/all — Delete all notifications for current user
|
||||||
|
// NOTE: Declared before /:id to avoid routing collision
|
||||||
|
router.delete('/all', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run('DELETE FROM notifications WHERE user_id = ?', [req.user.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Failed to delete notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/notifications/:id — Delete a single notification
|
||||||
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run(
|
||||||
|
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.user.id],
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Failed to delete notification' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { Bell, BellOff, CheckCheck, ExternalLink } from 'lucide-react';
|
import { Bell, BellOff, CheckCheck, ExternalLink, Trash2, X } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useNotifications } from '../contexts/NotificationContext';
|
import { useNotifications } from '../contexts/NotificationContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -48,7 +48,7 @@ function notificationSubtitle(n, t, lang) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificationBell() {
|
export default function NotificationBell() {
|
||||||
const { notifications, unreadCount, markRead, markAllRead } = useNotifications();
|
const { notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll } = useNotifications();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -70,6 +70,11 @@ export default function NotificationBell() {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (e, id) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await deleteNotification(id);
|
||||||
|
};
|
||||||
|
|
||||||
const recent = notifications.slice(0, 20);
|
const recent = notifications.slice(0, 20);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,16 +107,28 @@ export default function NotificationBell() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{unreadCount > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{unreadCount > 0 && (
|
||||||
onClick={markAllRead}
|
<button
|
||||||
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
onClick={markAllRead}
|
||||||
title={t('notifications.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')}
|
<CheckCheck size={14} />
|
||||||
</button>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
@@ -127,7 +144,7 @@ export default function NotificationBell() {
|
|||||||
<li
|
<li
|
||||||
key={n.id}
|
key={n.id}
|
||||||
onClick={() => handleNotificationClick(n)}
|
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
|
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'}`}
|
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -146,7 +163,7 @@ export default function NotificationBell() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unread dot + link indicator */}
|
{/* Right side: unread dot, link icon, delete button */}
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
{!n.read && (
|
{!n.read && (
|
||||||
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
||||||
@@ -154,6 +171,13 @@ export default function NotificationBell() {
|
|||||||
{n.link && (
|
{n.link && (
|
||||||
<ExternalLink size={12} className="text-th-text-s/50" />
|
<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 hover:text-th-error transition-all text-th-text-s/50"
|
||||||
|
title={t('notifications.delete')}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -71,8 +71,29 @@ export function NotificationProvider({ children }) {
|
|||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteNotification = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/notifications/${id}`);
|
||||||
|
setNotifications(prev => {
|
||||||
|
const removed = prev.find(n => n.id === id);
|
||||||
|
if (removed && !removed.read) setUnreadCount(c => Math.max(0, c - 1));
|
||||||
|
return prev.filter(n => n.id !== id);
|
||||||
|
});
|
||||||
|
seenIds.current.delete(id);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete('/notifications/all');
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
seenIds.current = new Set();
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, refresh: fetch }}>
|
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, deleteNotification, clearAll, refresh: fetch }}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationContext.Provider>
|
</NotificationContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -370,7 +370,9 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bell": "Benachrichtigungen",
|
"bell": "Benachrichtigungen",
|
||||||
"markAllRead": "Alle als gelesen markieren",
|
"markAllRead": "Alle gelesen",
|
||||||
|
"clearAll": "Alle löschen",
|
||||||
|
"delete": "Löschen",
|
||||||
"noNotifications": "Keine Benachrichtigungen",
|
"noNotifications": "Keine Benachrichtigungen",
|
||||||
"roomShareAdded": "Raum wurde mit dir geteilt",
|
"roomShareAdded": "Raum wurde mit dir geteilt",
|
||||||
"roomShareRemoved": "Raumzugriff wurde entfernt",
|
"roomShareRemoved": "Raumzugriff wurde entfernt",
|
||||||
|
|||||||
@@ -370,7 +370,9 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bell": "Notifications",
|
"bell": "Notifications",
|
||||||
"markAllRead": "Mark all as read",
|
"markAllRead": "Mark all read",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"delete": "Delete",
|
||||||
"noNotifications": "No notifications yet",
|
"noNotifications": "No notifications yet",
|
||||||
"roomShareAdded": "Room shared with you",
|
"roomShareAdded": "Room shared with you",
|
||||||
"roomShareRemoved": "Room access removed",
|
"roomShareRemoved": "Room access removed",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText } from 'lucide-react';
|
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText } from 'lucide-react';
|
||||||
import BrandLogo from '../components/BrandLogo';
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
@@ -10,6 +10,7 @@ import { useBranding } from '../contexts/BrandingContext';
|
|||||||
|
|
||||||
export default function GuestJoin() {
|
export default function GuestJoin() {
|
||||||
const { uid } = useParams();
|
const { uid } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { imprintUrl, privacyUrl } = useBranding();
|
const { imprintUrl, privacyUrl } = useBranding();
|
||||||
@@ -19,7 +20,7 @@ export default function GuestJoin() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [accessCode, setAccessCode] = useState('');
|
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
||||||
const [moderatorCode, setModeratorCode] = useState('');
|
const [moderatorCode, setModeratorCode] = useState('');
|
||||||
const [status, setStatus] = useState({ running: false });
|
const [status, setStatus] = useState({ running: false });
|
||||||
const [recordingConsent, setRecordingConsent] = useState(false);
|
const [recordingConsent, setRecordingConsent] = useState(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user