feat: Whiteboard Badge - Punkt bei neuen Änderungen/Teilen
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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}/>
|
||||||
|
|||||||
Reference in New Issue
Block a user