feat: HA-Entitaeten fuer Push-Erinnerungen und Kanban; neues Feature Sidebar-Sichtbarkeit pro Nutzer

This commit is contained in:
2026-07-10 20:47:57 +02:00
parent 37ebad91c6
commit d372bed860
6 changed files with 185 additions and 11 deletions

View File

@@ -258,6 +258,8 @@ if (!userCols2.includes('hidden'))
db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"); db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0");
if (!userCols2.includes('chat_active_at')) if (!userCols2.includes('chat_active_at'))
db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL"); db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL");
if (!userCols2.includes('hidden_tools'))
db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'");
// ── Login-Sicherheit ────────────────────────────────────────────────────────── // ── Login-Sicherheit ──────────────────────────────────────────────────────────
// Spalten für Account-Lockout in users-Tabelle // Spalten für Account-Lockout in users-Tabelle

View File

@@ -302,6 +302,8 @@ async function runScheduler() {
mqttClient.publishPresence(); mqttClient.publishPresence();
mqttClient.publishMediaAnfragen(); mqttClient.publishMediaAnfragen();
mqttClient.publishDruckStatistik(); mqttClient.publishDruckStatistik();
mqttClient.publishPushErinnerungen();
mqttClient.publishKanban();
try { try {
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
const due = db.prepare(` const due = db.prepare(`

View File

@@ -288,6 +288,28 @@ function publishDiscovery() {
availability_topic: AVAILABILITY_TOPIC, device: DEVICE, availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}], }],
// ── Dashboard ─────────────────────────────────────────────────────────
['sensor', 'dickendock_dashboard_push_erinnerungen', {
name: 'Dashboard Push-Erinnerungen offen',
unique_id: 'dickendock_dashboard_push_erinnerungen',
state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`,
json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`,
icon: 'mdi:bell-ring-outline',
unit_of_measurement: 'Erinnerungen', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
// ── Kanban ────────────────────────────────────────────────────────────
['sensor', 'dickendock_kanban_karten', {
name: 'Kanban Karten offen',
unique_id: 'dickendock_kanban_karten',
state_topic: `${BASE_TOPIC}/kanban/karten`,
json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`,
icon: 'mdi:view-column-outline',
unit_of_measurement: 'Karten', state_class: 'measurement',
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
}],
// ── System ──────────────────────────────────────────────────────────── // ── System ────────────────────────────────────────────────────────────
['sensor', 'dickendock_system_online', { ['sensor', 'dickendock_system_online', {
name: 'System Nutzer online', name: 'System Nutzer online',
@@ -542,6 +564,43 @@ function publishDruckStatistik() {
pub('druck/bestellungen_gesamt', allOrders.length); pub('druck/bestellungen_gesamt', allOrders.length);
} }
// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete,
// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit
function publishPushErinnerungen() {
if (!client?.connected) return;
const offen = db.prepare(`
SELECT s.message, s.scheduled_at, u.username
FROM push_schedules s JOIN users u ON u.id = s.user_id
WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime')
ORDER BY s.scheduled_at ASC
`).all();
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true });
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({
erinnerungen: offen,
}), { retain: true });
}
// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle
// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert)
function publishKanban() {
if (!client?.connected) return;
const karten = db.prepare(`
SELECT k.title, k.priority, col.title AS spalte, u.username
FROM kanban_cards k
JOIN kanban_columns col ON col.id = k.column_id
JOIN users u ON u.id = k.user_id
ORDER BY u.username ASC, col.position ASC, k.position ASC
`).all();
const proNutzer = {};
for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1;
client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true });
client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({
karten,
pro_nutzer: proNutzer,
}), { retain: true });
}
// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden // Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; } function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
function setMediaAckHandler(fn) { mediaAckHandler = fn; } function setMediaAckHandler(fn) { mediaAckHandler = fn; }
@@ -553,6 +612,8 @@ module.exports = {
publishPresence, publishPresence,
publishMediaAnfragen, publishMediaAnfragen,
publishDruckStatistik, publishDruckStatistik,
publishPushErinnerungen,
publishKanban,
setKoepiCheckHandler, setKoepiCheckHandler,
setMediaAckHandler, setMediaAckHandler,
setMediaAckAllHandler, setMediaAckAllHandler,

View File

@@ -32,7 +32,7 @@ router.post('/restore', authenticate, requireAdmin, upload.single('database'), (
// GET /api/admin/users alle Benutzer mit Statistiken // GET /api/admin/users alle Benutzer mit Statistiken
router.get('/users', authenticate, requireAdmin, (req, res) => { router.get('/users', authenticate, requireAdmin, (req, res) => {
const users = db.prepare(` const users = db.prepare(`
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools,
CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover
FROM users u FROM users u
LEFT JOIN pushover_settings p ON p.user_id = u.id LEFT JOIN pushover_settings p ON p.user_id = u.id
@@ -47,7 +47,9 @@ router.get('/users', authenticate, requireAdmin, (req, res) => {
const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c; const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c;
const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length; const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length;
const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c; const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c;
return { ...u, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen }; let hidden_tools = [];
try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {}
return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen };
}); });
res.json(stats); res.json(stats);
}); });
@@ -170,6 +172,18 @@ router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => {
res.json({ hidden: newHidden }); res.json({ hidden: newHidden });
}); });
// PUT /api/admin/users/:id/hidden-tools Sidebar-Sichtbarkeit für einen User setzen
// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js)
router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
const { hidden_tools } = req.body;
if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' });
const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))];
db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id);
res.json({ hidden_tools: cleaned });
});
// Test-Pushover an beliebigen User senden // Test-Pushover an beliebigen User senden
router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => { router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);

View File

@@ -166,4 +166,14 @@ router.put('/preferences', authenticate, (req, res) => {
res.json(merged); res.json(merged);
}); });
// ── GET /api/auth/hidden-tools ────────────────────────────────────────────────
// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche.
// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit
// darf nur der Admin ändern.
router.get('/hidden-tools', authenticate, (req, res) => {
const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id);
try { res.json(JSON.parse(user?.hidden_tools || '[]')); }
catch { res.json([]); }
});
module.exports = router; module.exports = router;

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, whiteboardUnread=0, isAdmin=false }) { function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) {
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({});
@@ -255,7 +255,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
{!collapsed && <span style={{ flex:1, color:'rgba(255,255,255,0.65)' }}>Apps</span>} {!collapsed && <span style={{ flex:1, color:'rgba(255,255,255,0.65)' }}>Apps</span>}
{!collapsed && <ChevronIcon size={13} color="rgba(255,255,255,0.35)" dir={appsOpen?'down':'right'}/>} {!collapsed && <ChevronIcon size={13} color="rgba(255,255,255,0.35)" dir={appsOpen?'down':'right'}/>}
</button> </button>
{appsOpen && getGroupedTools(isAdmin).map(([group, tools]) => { {appsOpen && getGroupedTools(isAdmin, hiddenTools).map(([group, tools]) => {
const isGroupCollapsed = !!collapsedGroups[group]; const isGroupCollapsed = !!collapsedGroups[group];
return ( return (
<div key={group}> <div key={group}>
@@ -296,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, whiteboardUnread=0, isAdmin=false }) { function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) {
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({});
@@ -360,7 +360,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur
{/* Apps Sheet mit Gruppenüberschriften */} {/* Apps Sheet mit Gruppenüberschriften */}
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}> <Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
<div style={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}> <div style={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}>
{getGroupedTools(user?.role==='admin').map(([group, tools]) => { {getGroupedTools(user?.role==='admin', hiddenTools).map(([group, tools]) => {
const isGrpCollapsed = !!mobileCollapsedGroups[group]; const isGrpCollapsed = !!mobileCollapsedGroups[group];
return ( return (
<div key={group}> <div key={group}>
@@ -2380,6 +2380,8 @@ function UserManagement({ toast }) {
const [resetId, setResetId] = useState(null); const [resetId, setResetId] = useState(null);
const [resetPw, setResetPw] = useState(''); const [resetPw, setResetPw] = useState('');
const [delId, setDelId] = useState(null); const [delId, setDelId] = useState(null);
const [visId, setVisId] = useState(null);
const [visSelection, setVisSelection] = useState([]);
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
const load = () => { const load = () => {
@@ -2411,6 +2413,24 @@ function UserManagement({ toast }) {
} catch(e) { toast(e.message,'error'); } } catch(e) { toast(e.message,'error'); }
}; };
const openVisibility = (u) => {
setVisId(u.id);
setVisSelection(u.hidden_tools || []);
};
const toggleVisTool = (toolId) => {
setVisSelection(prev => prev.includes(toolId) ? prev.filter(t=>t!==toolId) : [...prev, toolId]);
};
const saveVisibility = async () => {
try {
await api(`/admin/users/${visId}/hidden-tools`,{method:'PUT',body:{hidden_tools:visSelection}});
setUsers(p=>p.map(x=>x.id===visId?{...x,hidden_tools:visSelection}:x));
toast('Sichtbarkeit gespeichert');
setVisId(null);
} catch(e) { toast(e.message,'error'); }
};
return ( return (
<div> <div>
<ConfirmDialog/> <ConfirmDialog/>
@@ -2448,6 +2468,51 @@ function UserManagement({ toast }) {
</div> </div>
)} )}
{/* Sidebar-Sichtbarkeit Modal */}
{visId && (() => {
const grouped = {};
for (const t of TOOLS) {
const g = t.group || 'Allgemein';
if (!grouped[g]) grouped[g] = [];
grouped[g].push(t);
}
const target = users.find(u=>u.id===visId);
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:5000,
display:'flex',alignItems:'center',justifyContent:'center',padding:20}}>
<div style={{...S.card,width:'100%',maxWidth:420,maxHeight:'80vh',overflowY:'auto'}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700,marginBottom:4}}>
Sichtbare Bereiche{target ? `${target.username}` : ''}
</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:14}}>
Angehakt = ausgeblendet für diesen Nutzer
</div>
{Object.entries(grouped).map(([group, tools]) => (
<div key={group} style={{marginBottom:14}}>
<div style={{...S.head,marginBottom:6}}>{group}</div>
{tools.map(t => (
<label key={t.id} style={{
display:'flex',alignItems:'center',gap:8,padding:'6px 2px',cursor:'pointer',
}}>
<input type="checkbox" checked={visSelection.includes(t.id)}
onChange={()=>toggleVisTool(t.id)}
style={{width:15,height:15,accentColor:'#ff6b9d',flexShrink:0}}/>
<span style={{color:'rgba(255,255,255,0.75)',fontFamily:'monospace',fontSize:12}}>
{t.label}{t.adminOnly ? ' (nur Admin)' : ''}
</span>
</label>
))}
</div>
))}
<div style={{display:'flex',gap:8,marginTop:8}}>
<button onClick={saveVisibility} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0'}}> Speichern</button>
<button onClick={()=>setVisId(null)} style={{...S.btn('#ff6b9d'),flex:1,textAlign:'center',padding:'10px 0'}}>Abbrechen</button>
</div>
</div>
</div>
);
})()}
{/* Benutzerliste */} {/* Benutzerliste */}
<Sec title="BENUTZER"> <Sec title="BENUTZER">
{loading && <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Lädt</div>} {loading && <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
@@ -2508,6 +2573,11 @@ function UserManagement({ toast }) {
)} )}
<button onClick={()=>{setResetId(u.id);setResetPw('');}} <button onClick={()=>{setResetId(u.id);setResetPw('');}}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🔑</button> style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🔑</button>
<button onClick={()=>openVisibility(u)}
title="Sichtbare Bereiche"
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}>
🧩{u.hidden_tools?.length ? ` ${u.hidden_tools.length}` : ''}
</button>
<button <button
onClick={async ()=>{ onClick={async ()=>{
if (u.role==='admin') return; if (u.role==='admin') return;
@@ -3702,6 +3772,7 @@ export default function App() {
return window.matchMedia('(display-mode: standalone)').matches && !sessionStorage.getItem('splash_shown'); return window.matchMedia('(display-mode: standalone)').matches && !sessionStorage.getItem('splash_shown');
}); });
const [user,setUser]=useState(null); const [user,setUser]=useState(null);
const [hiddenTools,setHiddenTools]=useState([]);
const [active,setActiveRaw]=useState('dashboard'); const [active,setActiveRaw]=useState('dashboard');
const [devToolsNav, setDevToolsNav] = useState(null); const [devToolsNav, setDevToolsNav] = useState(null);
const pollUnackedFavs = () => api('/tools/media/favorites/unacked').then(d=>setUnackedFavs({count:d.count||0,isAdmin:d.isAdmin})).catch(()=>{}); const pollUnackedFavs = () => api('/tools/media/favorites/unacked').then(d=>setUnackedFavs({count:d.count||0,isAdmin:d.isAdmin})).catch(()=>{});
@@ -3741,6 +3812,20 @@ export default function App() {
if(!user)return; if(!user)return;
},[user]); },[user]);
// Vom Admin ausgeblendete Sidebar-Bereiche laden (einmal pro Login)
useEffect(()=>{
if(!user)return;
api('/auth/hidden-tools').then(setHiddenTools).catch(()=>{});
},[user]);
// Falls der aktuell offene Bereich inzwischen ausgeblendet wurde (z.B. Admin
// hat während der laufenden Session die Sichtbarkeit geändert): zurück zum Dashboard
useEffect(()=>{
if (active !== 'dashboard' && active !== 'admin' && hiddenTools.includes(active)) {
setActiveRaw('dashboard');
}
},[active, hiddenTools]);
// Schlüssel sofort nach Login initialisieren und Public Key registrieren // Schlüssel sofort nach Login initialisieren und Public Key registrieren
useEffect(()=>{ useEffect(()=>{
if(!user)return; if(!user)return;
@@ -3806,7 +3891,7 @@ export default function App() {
if(!authChecked) return <div style={{background:'#0d0d0f',minHeight:'100vh'}}/>; if(!authChecked) return <div style={{background:'#0d0d0f',minHeight:'100vh'}}/>;
if(!user) return <Login onLogin={u=>setUser(u)}/>; if(!user) return <Login onLogin={u=>setUser(u)}/>;
const activeTool = TOOLS.find(t=>t.id===active); const activeTool = hiddenTools.includes(active) ? null : TOOLS.find(t=>t.id===active);
// Mobile padding bottom für fixed nav // Mobile padding bottom für fixed nav
const isNachrichten = active === 'nachrichten'; const isNachrichten = active === 'nachrichten';
@@ -3832,9 +3917,9 @@ export default function App() {
<div style={{display:'flex',flexDirection:'row',height:'100dvh',background:'#111114'}}> <div style={{display:'flex',flexDirection:'row',height:'100dvh',background:'#111114'}}>
{!mobile && ( {!mobile && (
<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);setHiddenTools([]);}}
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/> unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'} hiddenTools={hiddenTools}/>
)} )}
<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}/>}
@@ -3844,9 +3929,9 @@ export default function App() {
</div> </div>
{mobile && ( {mobile && (
<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);setHiddenTools([]);}}
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/> unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'} hiddenTools={hiddenTools}/>
)} )}
<ScrollToTop scrollRef={mainRef}/> <ScrollToTop scrollRef={mainRef}/>
<Toast msg={toastMsg.msg} type={toastMsg.type}/> <Toast msg={toastMsg.msg} type={toastMsg.type}/>