feat: Whiteboard Badge - Punkt bei neuen Änderungen/Teilen

This commit is contained in:
2026-06-29 13:33:41 +02:00
parent fbc3acb2c6
commit 31cb7f7411
2 changed files with 67 additions and 13 deletions

View File

@@ -25,11 +25,41 @@ const router = express.Router();
role TEXT NOT NULL DEFAULT 'edit', role TEXT NOT NULL DEFAULT 'edit',
UNIQUE(whiteboard_id, user_id) 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; 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 // Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück
function access(whiteboardId, userId) { function access(whiteboardId, userId) {
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId); 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; 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 ───────────────────────────────────────────────────────────────────── // ── Liste ─────────────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => { router.get('/', authenticate, (req, res) => {
const me = uid(req); 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=? JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
`).all(req.params.id); `).all(req.params.id);
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_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 }); 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.`; const message = `${saver?.username || 'Jemand'} hat das Whiteboard "${wb?.title || ''}" gespeichert.`;
// Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst // Alle User mit Zugriff: Owner + alle Permissions — außer dem Speichernden selbst
const notify = []; const notify = getAccessUsers(req.params.id, me);
if (wb && wb.owner_id !== me) notify.push(wb.owner_id); markUnread(req.params.id, notify, 'updated');
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); }
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); 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; if (!poCfg?.app_token || !poCfg?.user_key) continue;
fetch('https://api.pushover.net/1/messages.json', { 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 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)); 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) { for (const p of newlyAdded) {
try { try {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id);

View File

@@ -155,7 +155,7 @@ function Login({ onLogin }) {
// ── Update Modal ────────────────────────────────────────────────────────────── // ── Update Modal ──────────────────────────────────────────────────────────────
// ── Desktop Sidebar ─────────────────────────────────────────────────────────── // ── 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 [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState({}); const [collapsedGroups, setCollapsedGroups] = useState({});
@@ -186,6 +186,10 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7, <span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/> borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
)} )}
{collapsed && item.id==='whiteboard' && whiteboardUnread>0 && (
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
)}
</span> </span>
{!collapsed && <span style={{ flex:1, color:ic }}>{item.label}</span>} {!collapsed && <span style={{ flex:1, color:ic }}>{item.label}</span>}
{!collapsed && item.id==='nachrichten' && unreadMsgs>0 && ( {!collapsed && item.id==='nachrichten' && unreadMsgs>0 && (
@@ -196,6 +200,10 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9, <span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9,
fontFamily:'monospace', padding:'1px 6px', fontWeight:700, flexShrink:0 }}>{hexWarsTurns}</span> fontFamily:'monospace', padding:'1px 6px', fontWeight:700, flexShrink:0 }}>{hexWarsTurns}</span>
)} )}
{!collapsed && item.id==='whiteboard' && whiteboardUnread>0 && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9,
fontFamily:'monospace', padding:'1px 6px', fontWeight:700, flexShrink:0 }}>{whiteboardUnread}</span>
)}
</button> </button>
); );
}; };
@@ -288,7 +296,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
} }
// ── Mobile Bottom Navigation ────────────────────────────────────────────────── // ── 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 [menuOpen, setMenuOpen] = useState(false);
const [appsOpen, setAppsOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false);
const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({}); const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({});
@@ -365,7 +373,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur
</button> </button>
{!isGrpCollapsed && tools.map(t => ( {!isGrpCollapsed && tools.map(t => (
<MI key={t.id} Icon={t.Icon} <MI key={t.id} Icon={t.Icon}
label={t.label} dot={t.id==='gebietseroberung' && hexWarsTurns>0} label={t.label} dot={(t.id==='gebietseroberung' && hexWarsTurns>0) || (t.id==='whiteboard' && whiteboardUnread>0)}
onClick={() => { setActive(t.id); setAppsOpen(false); }} /> onClick={() => { setActive(t.id); setAppsOpen(false); }} />
))} ))}
</div> </div>
@@ -399,7 +407,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur
style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center', style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center',
justifyContent:'center', gap:3, background:'transparent', border:'none', justifyContent:'center', gap:3, background:'transparent', border:'none',
cursor:'pointer', position:'relative', minWidth:0, padding:'0 4px' }}> cursor:'pointer', position:'relative', minWidth:0, padding:'0 4px' }}>
{isApps && (hexWarsTurns>0 || unreadMsgs>0) && ( {isApps && (hexWarsTurns>0 || unreadMsgs>0 || whiteboardUnread>0) && (
<span style={{ position:'absolute', top:6, right:'calc(50% - 16px)', <span style={{ position:'absolute', top:6, right:'calc(50% - 16px)',
width:8, height:8, borderRadius:'50%', background:'#4ecdc4', width:8, height:8, borderRadius:'50%', background:'#4ecdc4',
boxShadow:'0 0 6px #4ecdc4', display:'inline-block' }} /> boxShadow:'0 0 6px #4ecdc4', display:'inline-block' }} />
@@ -3708,6 +3716,7 @@ export default function App() {
const [toastMsg,setToastMsg]=useState({msg:'',type:'success'}); const [toastMsg,setToastMsg]=useState({msg:'',type:'success'});
const [unreadMsgs, setUnreadMsgs] = useState(0); 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 [unreadBoard, setUnreadBoard] = useState(0);
const [unreadPromoted, setUnreadPromoted] = useState(0); const [unreadPromoted, setUnreadPromoted] = useState(0);
const [unreadChangelog,setUnreadChangelog]= useState(0); const [unreadChangelog,setUnreadChangelog]= useState(0);
@@ -3779,6 +3788,14 @@ export default function App() {
return()=>clearInterval(t); return()=>clearInterval(t);
},[user]); },[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')=>{ const toast=useCallback((msg,type='success')=>{
setToastMsg({msg,type}); setToastMsg({msg,type});
setTimeout(()=>setToastMsg({msg:'',type:'success'}),3500); setTimeout(()=>setToastMsg({msg:'',type:'success'}),3500);
@@ -3816,7 +3833,7 @@ export default function App() {
<Sidebar active={active} setActive={setActive} user={user} <Sidebar active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}} onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns}/> unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/>
)} )}
<main ref={mainRef} style={mainStyle}> <main ref={mainRef} style={mainStyle}>
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>} {active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ 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() {
<BottomNav active={active} setActive={setActive} user={user} <BottomNav active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}} onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns}/> unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/>
)} )}
<ScrollToTop scrollRef={mainRef}/> <ScrollToTop scrollRef={mainRef}/>
<Toast msg={toastMsg.msg} type={toastMsg.type}/> <Toast msg={toastMsg.msg} type={toastMsg.type}/>