From 31cb7f74114d9b4d719033218e440b3bfbd580d2 Mon Sep 17 00:00:00 2001 From: Dicken Date: Mon, 29 Jun 2026 13:33:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Whiteboard=20Badge=20-=20Punkt=20bei=20?= =?UTF-8?q?neuen=20=C3=84nderungen/Teilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/whiteboard/routes.js | 49 ++++++++++++++++++++++---- frontend/src/App.jsx | 31 ++++++++++++---- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/backend/src/tools/whiteboard/routes.js b/backend/src/tools/whiteboard/routes.js index 1f11370..6f9540d 100644 --- a/backend/src/tools/whiteboard/routes.js +++ b/backend/src/tools/whiteboard/routes.js @@ -25,11 +25,41 @@ const router = express.Router(); role TEXT NOT NULL DEFAULT 'edit', UNIQUE(whiteboard_id, user_id) ); + CREATE TABLE IF NOT EXISTS whiteboard_unread ( + whiteboard_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT 'updated', + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + PRIMARY KEY (whiteboard_id, user_id) + ); `); })(); const uid = req => req.user.id; +function getAccessUsers(whiteboardId, exceptUserId) { + const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId); + if (!wb) return []; + const users = new Set(); + if (wb.owner_id !== exceptUserId) users.add(wb.owner_id); + const perms = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(whiteboardId); + for (const p of perms) if (p.user_id !== exceptUserId) users.add(p.user_id); + return [...users]; +} + +function markUnread(whiteboardId, userIds, reason = 'updated') { + const upsert = db.prepare(` + INSERT INTO whiteboard_unread (whiteboard_id, user_id, reason, created_at) + VALUES (?, ?, ?, datetime('now','localtime')) + ON CONFLICT(whiteboard_id, user_id) DO UPDATE SET reason=excluded.reason, created_at=excluded.created_at + `); + for (const u of userIds) upsert.run(whiteboardId, u, reason); +} + +function markRead(whiteboardId, userId) { + db.prepare('DELETE FROM whiteboard_unread WHERE whiteboard_id=? AND user_id=?').run(whiteboardId, userId); +} + // Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück function access(whiteboardId, userId) { const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId); @@ -39,6 +69,13 @@ function access(whiteboardId, userId) { return perm?.role || null; } +// ── Unread count ────────────────────────────────────────────────────────────── +router.get('/unread', authenticate, (req, res) => { + const me = uid(req); + const { count } = db.prepare('SELECT COUNT(*) as count FROM whiteboard_unread WHERE user_id=?').get(me); + res.json({ count }); +}); + // ── Liste ───────────────────────────────────────────────────────────────────── router.get('/', authenticate, (req, res) => { const me = uid(req); @@ -100,6 +137,7 @@ router.get('/:id/data', authenticate, (req, res) => { JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=? `).all(req.params.id); const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id); + markRead(req.params.id, me); res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms }); }); @@ -123,12 +161,10 @@ router.post('/:id/save', authenticate, async (req, res) => { const message = `${saver?.username || 'Jemand'} hat das Whiteboard "${wb?.title || ''}" gespeichert.`; // Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst - const notify = []; - if (wb && wb.owner_id !== me) notify.push(wb.owner_id); - const perms = db.prepare('SELECT user_id FROM whiteboard_permissions WHERE whiteboard_id=?').all(req.params.id); - for (const p of perms) { if (p.user_id !== me) notify.push(p.user_id); } + const notify = getAccessUsers(req.params.id, me); + markUnread(req.params.id, notify, 'updated'); - for (const userId of [...new Set(notify)]) { + for (const userId of notify) { const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); if (!poCfg?.app_token || !poCfg?.user_key) continue; fetch('https://api.pushover.net/1/messages.json', { @@ -175,9 +211,10 @@ router.put('/:id/permissions', authenticate, async (req, res) => { } })(); - // Pushover an neu hinzugekommene User (fire & forget) const owner = db.prepare('SELECT username FROM users WHERE id=?').get(me); const newlyAdded = permissions.filter(p => p.role && p.role !== 'none' && !before.includes(p.user_id)); + if (newlyAdded.length) markUnread(req.params.id, newlyAdded.map(p => p.user_id), 'shared'); + for (const p of newlyAdded) { try { const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a13f0b8..ffed280 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -155,7 +155,7 @@ function Login({ onLogin }) { // ── Update Modal ────────────────────────────────────────────────────────────── // ── Desktop Sidebar ─────────────────────────────────────────────────────────── -function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0 }) { +function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0 }) { const [appsOpen, setAppsOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState({}); @@ -186,6 +186,10 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns )} + {collapsed && item.id==='whiteboard' && whiteboardUnread>0 && ( + + )} {!collapsed && {item.label}} {!collapsed && item.id==='nachrichten' && unreadMsgs>0 && ( @@ -196,6 +200,10 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns {hexWarsTurns} )} + {!collapsed && item.id==='whiteboard' && whiteboardUnread>0 && ( + {whiteboardUnread} + )} ); }; @@ -288,7 +296,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns } // ── Mobile Bottom Navigation ────────────────────────────────────────────────── -function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0 }) { +function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0 }) { const [menuOpen, setMenuOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false); const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({}); @@ -365,7 +373,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur {!isGrpCollapsed && tools.map(t => ( 0} + label={t.label} dot={(t.id==='gebietseroberung' && hexWarsTurns>0) || (t.id==='whiteboard' && whiteboardUnread>0)} onClick={() => { setActive(t.id); setAppsOpen(false); }} /> ))} @@ -399,7 +407,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:3, background:'transparent', border:'none', cursor:'pointer', position:'relative', minWidth:0, padding:'0 4px' }}> - {isApps && (hexWarsTurns>0 || unreadMsgs>0) && ( + {isApps && (hexWarsTurns>0 || unreadMsgs>0 || whiteboardUnread>0) && ( @@ -3707,7 +3715,8 @@ export default function App() { }; const [toastMsg,setToastMsg]=useState({msg:'',type:'success'}); const [unreadMsgs, setUnreadMsgs] = useState(0); - const [hexWarsTurns, setHexWarsTurns] = useState(0); + const [hexWarsTurns, setHexWarsTurns] = useState(0); + const [whiteboardUnread, setWhiteboardUnread] = useState(0); const [unreadBoard, setUnreadBoard] = useState(0); const [unreadPromoted, setUnreadPromoted] = useState(0); const [unreadChangelog,setUnreadChangelog]= useState(0); @@ -3779,6 +3788,14 @@ export default function App() { return()=>clearInterval(t); },[user]); + useEffect(()=>{ + if(!user)return; + const poll=()=>api('/tools/whiteboard/unread').then(d=>setWhiteboardUnread(d.count||0)).catch(()=>{}); + poll(); + const t=setInterval(poll,30*1000); + return()=>clearInterval(t); + },[user]); + const toast=useCallback((msg,type='success')=>{ setToastMsg({msg,type}); setTimeout(()=>setToastMsg({msg:'',type:'success'}),3500); @@ -3816,7 +3833,7 @@ export default function App() { {localStorage.removeItem('sk_token');setUser(null);}} - unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns}/> + unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/> )}
{active==='dashboard' && { setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>} @@ -3828,7 +3845,7 @@ export default function App() { {localStorage.removeItem('sk_token');setUser(null);}} - unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns}/> + unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/> )}