Files
dickendock/frontend/src/App.jsx

2507 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from './lib.js';
import { TOOLS, getGroupedTools } from './toolRegistry.js';
import CalendarWidget, { CalendarSettings } from './calendar.jsx';
import { DateiSettings } from './tools/dateien.jsx';
import { useConfirm } from './confirm.jsx';
import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon, MessageIcon } from './icons.jsx';
import { getOrCreateKeyPair, getPublicKeyJwk, getFingerprint, exportEncrypted, importEncrypted, generateNewKeyPair } from './crypto.js';
const CONST_ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'];
// Schlüsselpaar beim Login generieren und public key registrieren (fire & forget)
async function initNachrichtenKeys() {
const LS_KEY = 'dd_msg_keypair';
let pubJwk;
const stored = localStorage.getItem(LS_KEY);
if (stored) {
try { pubJwk = JSON.parse(stored).pub; } catch { localStorage.removeItem(LS_KEY); }
}
if (!pubJwk) {
const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']);
pubJwk = await crypto.subtle.exportKey('jwk', kp.publicKey);
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
localStorage.setItem(LS_KEY, JSON.stringify({ pub: pubJwk, priv }));
}
try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(pubJwk) } }); } catch {}
}
// ── Responsive Hook ───────────────────────────────────────────────────────────
function useIsMobile() {
const [mobile, setMobile] = useState(window.innerWidth < 768);
useEffect(() => {
// Nur auf Breite reagieren, nicht auf Keyboard-Open (Höhenänderung)
const fn = () => {
const isMob = window.innerWidth < 768;
setMobile(prev => prev === isMob ? prev : isMob);
};
window.addEventListener('resize', fn);
return () => window.removeEventListener('resize', fn);
}, []);
return mobile;
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function Toast({ msg, type }) {
if (!msg) return null;
const c = type === 'error' ? '#ff6b9d' : '#4ecdc4';
return (
<div style={{ position:'fixed', bottom:80, right:16, zIndex:9999, background:'#1a1a1e',
border:`1px solid ${c}55`, borderRadius:10, padding:'10px 16px',
color:c, fontFamily:'monospace', fontSize:12, animation:'fadeIn 0.2s ease',
boxShadow:`0 4px 24px ${c}22`, maxWidth:'calc(100vw - 32px)' }}>
{type==='error'?'✕ ':'✓ '}{msg}
</div>
);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
function Modal({ title, onClose, children }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.8)', zIndex:5000,
display:'flex',
alignItems: window.innerWidth>=768 ? 'center' : 'flex-end',
justifyContent:'center', padding: window.innerWidth>=768 ? 24 : 0 }}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.15)',
borderRadius: window.innerWidth>=768 ? 16 : '16px 16px 0 0',
padding:'20px 20px 40px', width:'100%',
maxWidth:600, maxHeight:'85vh', overflowY:'auto',
boxShadow: window.innerWidth>=768 ? '0 24px 80px rgba(0,0,0,0.6)' : 'none' }}>
{window.innerWidth < 768 && <div style={{ width:36, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, margin:'0 auto 20px' }}/>}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:14, fontWeight:700 }}>{title}</span>
<button onClick={onClose} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)', fontSize:20, cursor:'pointer', padding:'0 4px' }}></button>
</div>
{children}
</div>
</div>
);
}
// ── PWA Splash Screen ─────────────────────────────────────────────────────────
function SplashScreen({ onDone }) {
const [fade, setFade] = useState(false);
useEffect(() => {
const t1 = setTimeout(() => setFade(true), 1200);
const t2 = setTimeout(() => onDone(), 1700);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, []);
return (
<div style={{
position:'fixed', inset:0, background:'#0d0d0f',
display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center',
zIndex:99999, transition:'opacity 0.5s ease',
opacity: fade ? 0 : 1, pointerEvents: fade ? 'none' : 'auto',
}}>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:22, fontWeight:700, marginBottom:24, letterSpacing:2 }}>
DICKEN<span style={{color:'#4ecdc4'}}>DOCK</span>
</div>
<div style={{
width:72, height:72, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:18, display:'flex', alignItems:'center', justifyContent:'center', fontSize:36,
}}></div>
</div>
);
}
// ── Login ─────────────────────────────────────────────────────────────────────
function Login({ onLogin }) {
const [u,setU]=useState(''); const [p,setP]=useState('');
const [err,setErr]=useState(''); const [busy,setBusy]=useState(false);
const go = async () => {
setErr(''); setBusy(true);
try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); initNachrichtenKeys(); onLogin(d.user); }
catch(e) { setErr(e.message); } finally { setBusy(false); }
};
return (
<div style={{
position:'fixed', inset:0,
background:'#0d0d0f',
display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
padding:'24px 20px', overflow:'hidden',
}}>
<div style={{ width:'100%', maxWidth:340 }}>
<div style={{ textAlign:'center', marginBottom:36 }}>
<div style={{ width:56, height:56, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:14, display:'flex', alignItems:'center', justifyContent:'center',
fontSize:26, margin:'0 auto 14px' }}></div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:20, fontWeight:700 }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
</div>
{[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>(
<div key={l} style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:5 }}>{l}</label>
<input type={t} value={v} onChange={e=>s(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&go()}
style={{ ...S.inp, fontSize:16, padding:'13px 14px', borderRadius:10,
border:'1px solid rgba(255,255,255,0.12)' }}
autoCapitalize="none" autoCorrect="off" />
</div>
))}
{err && <div style={{ color:'#ff6b9d', fontSize:12, fontFamily:'monospace', marginBottom:12, textAlign:'center' }}> {err}</div>}
<button onClick={go} disabled={busy} style={{
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
borderRadius:12, padding:'15px 0', color:'#0d0d0f',
fontFamily:"'Space Mono',monospace", fontWeight:700, fontSize:15,
cursor:busy?'default':'pointer', opacity:busy?0.7:1, marginTop:6,
}}>{busy?'…':'Einloggen →'}</button>
</div>
</div>
);
}
// ── Update Modal ──────────────────────────────────────────────────────────────
function UpdateModal({ data, onClose }) {
return (
<Modal title="📦 Update verfügbar" onClose={onClose}>
<div style={{ marginBottom:14, padding:'8px 12px', background:'rgba(255,230,109,0.06)',
border:'1px solid rgba(255,230,109,0.2)', borderRadius:8 }}>
<span style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace' }}>
Installiert: <span style={{ color:'#4ecdc4' }}>{data.currentVersion}</span>
{' → '}<span style={{ color:'#ffe66d' }}>{data.latestVersion}</span>
</span>
</div>
{(data.releases||[]).map((r,i)=>(
<div key={i} style={{ marginBottom:12, padding:'12px 14px',
background:i===0?'rgba(78,205,196,0.05)':'rgba(255,255,255,0.02)',
border:`1px solid ${i===0?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)'}`, borderRadius:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
<span style={{ color:i===0?'#4ecdc4':'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>{r.name||r.version}</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontSize:10, fontFamily:'monospace' }}>
{r.publishedAt?new Date(r.publishedAt).toLocaleDateString('de-DE'):''}
</span>
</div>
<pre style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace', whiteSpace:'pre-wrap', lineHeight:1.6, margin:0 }}>{r.body}</pre>
</div>
))}
<div style={{ color:'rgba(255,255,255,0.55)', fontSize:11, fontFamily:'monospace', marginTop:8, lineHeight:1.8 }}>
Update: <code style={{ color:'#4ecdc4' }}>docker compose up -d --build</code>
</div>
</Modal>
);
}
// ── Desktop Sidebar ───────────────────────────────────────────────────────────
function Sidebar({ active, setActive, user, onLogout, updateInfo, onUpdateClick, unreadMsgs=0 }) {
const [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const NavBtn = ({ item, sub=false }) => {
const ic = active===item.id ? '#4ecdc4' : 'rgba(255,255,255,0.55)';
return (
<button onClick={()=>setActive(item.id)} title={collapsed ? item.label : undefined} style={{
width:'100%', display:'flex', alignItems:'center', gap:9,
padding: collapsed ? '8px 0' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius:7, border:'none',
background: active===item.id ? 'linear-gradient(90deg,rgba(78,205,196,0.14),rgba(78,205,196,0.02))' : 'transparent',
cursor:'pointer', fontSize:12,
fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft: active===item.id ? '2px solid #4ecdc4' : '2px solid transparent',
position:'relative',
}}>
{!collapsed && sub && <span style={{ width:1, alignSelf:'stretch', background:'rgba(255,255,255,0.08)', marginRight:3, flexShrink:0 }}/>}
<span style={{ display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0, position:'relative' }}>
{item.Icon ? (() => { const I = item.Icon; return <I size={collapsed?18:15} color={ic}/>; })() : <span style={{color:ic,fontSize:collapsed?16:13}}>{item.icon}</span>}
{collapsed && item.id==='nachrichten' && unreadMsgs>0 && (
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
)}
</span>
{!collapsed && <span style={{ flex:1, color:ic }}>{item.label}</span>}
{!collapsed && item.id==='nachrichten' && unreadMsgs>0 && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9,
fontFamily:'monospace', padding:'1px 6px', fontWeight:700, flexShrink:0 }}>{unreadMsgs}</span>
)}
</button>
);
};
return (
<aside style={{ width:collapsed?56:210, minHeight:'100vh', background:'#0d0d0f',
borderRight:'1px solid rgba(255,255,255,0.06)', display:'flex', flexDirection:'column', flexShrink:0,
transition:'width 0.2s ease', overflow:'hidden', position:'relative' }}>
<div style={{ padding:'12px 8px', borderBottom:'1px solid rgba(255,255,255,0.06)', marginBottom:8 }}>
{collapsed ? (
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', gap:8 }}>
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14 }}></div>
<button onClick={()=>setCollapsed(false)} style={{
background:'rgba(78,205,196,0.2)', border:'1px solid rgba(78,205,196,0.5)',
borderRadius:6, color:'#4ecdc4', cursor:'pointer', padding:'4px 6px',
fontSize:14, lineHeight:1, fontWeight:700, width:'100%' }}></button>
</div>
) : (
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14, flexShrink:0 }}></div>
<div style={{flex:1,minWidth:0}}>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700, whiteSpace:'nowrap' }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
</div>
<button onClick={()=>setCollapsed(true)} style={{
background:'rgba(78,205,196,0.15)', border:'1px solid rgba(78,205,196,0.4)',
borderRadius:6, color:'#4ecdc4', cursor:'pointer', padding:'4px 8px', flexShrink:0,
fontSize:14, lineHeight:1, fontWeight:700 }}></button>
</div>
)}
</div>
<nav style={{ flex:1, padding:'0 8px' }}>
<NavBtn item={{ id:'dashboard', Icon:HomeIcon, label:'Dashboard' }} />
{/* Apps Sektion mit Gruppenüberschriften */}
<button onClick={()=>setAppsOpen(v=>!v)} title={collapsed?'Apps':undefined} style={{
width:'100%', display:'flex', alignItems:'center', gap:9,
padding: collapsed ? '9px 0' : '9px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius:7, border:'none', background:'transparent',
cursor:'pointer', fontSize:12,
fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft:'2px solid transparent',
}}>
<AppsIcon size={collapsed?18:15} color="rgba(255,255,255,0.6)"/>
{!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'}/>}
</button>
{appsOpen && getGroupedTools().map(([group, tools]) => (
<div key={group}>
{!collapsed && <div style={{ color:'rgba(78,205,196,0.85)', fontSize:9, fontFamily:'monospace',
letterSpacing:2, padding:'8px 12px 3px 12px' }}>{group.toUpperCase()}</div>}
{tools.map(t => <NavBtn key={t.id} item={t} sub />)}
</div>
))}
<NavBtn item={{ id:'admin', Icon:AdminIcon, label:'Einstellungen' }} />
</nav>
{updateInfo?.hasUpdate && (
<button onClick={onUpdateClick} style={{
margin:'0 8px 8px', background:'rgba(255,230,109,0.08)',
border:'1px solid rgba(255,230,109,0.3)', borderRadius:8,
padding:'8px 12px', cursor:'pointer', display:'flex', alignItems:'center', gap:8,
}}>
<span style={{ width:7, height:7, borderRadius:'50%', background:'#ffe66d', flexShrink:0, boxShadow:'0 0 6px #ffe66d', display:'inline-block' }} />
<span style={{ color:'#ffe66d', fontSize:11, fontFamily:'monospace' }}>Update</span>
<span style={{ color:'rgba(255,230,109,0.5)', fontSize:10, fontFamily:'monospace', marginLeft:'auto' }}>{updateInfo.latestVersion}</span>
</button>
)}
<div style={{ padding:'10px 12px 16px', borderTop:'1px solid rgba(255,255,255,0.06)' }}>
{!collapsed && <><div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
<div style={{ width:24, height:24, borderRadius:'50%', background:'linear-gradient(135deg,#ff6b9d,#4ecdc4)',
display:'flex', alignItems:'center', justifyContent:'center', overflow:'hidden', flexShrink:0 }}>
{localStorage.getItem('dd_avatar')
? <img src={localStorage.getItem('dd_avatar')} style={{width:'100%',height:'100%',objectFit:'cover'}} alt=""/>
: <UserIcon size={14} color="#0d0d0f"/>}
</div>
<div style={{ color:'#fff', fontSize:11, fontFamily:'monospace' }}>{user?.username}</div>
</div>
<button onClick={onLogout} style={{ ...S.btn('#ff6b9d',true), width:'100%', textAlign:'center' }}>Ausloggen</button></>}
{collapsed && <button onClick={onLogout} title="Ausloggen" style={{ background:'transparent', border:'none', cursor:'pointer', padding:'4px', width:'100%', display:'flex', justifyContent:'center' }}><LogOutIcon size={18} color="rgba(255,107,157,0.7)"/></button>}
</div>
</aside>
);
}
// ── Mobile Bottom Navigation ──────────────────────────────────────────────────
function BottomNav({ active, setActive, user, onLogout, updateInfo, onUpdateClick, unreadMsgs=0 }) {
const [menuOpen, setMenuOpen] = useState(false);
const [appsOpen, setAppsOpen] = useState(false);
const NAV_H = 56;
const MI = ({Icon, icon, label, onClick, color='rgba(255,255,255,0.85)'}) => {
const Ic = Icon;
return (
<button onClick={onClick} style={{ width:'100%', padding:'11px 20px', background:'transparent',
border:'none', display:'flex', alignItems:'center', gap:12, cursor:'pointer' }}>
<span style={{flexShrink:0, width:22, display:'flex', alignItems:'center', justifyContent:'center'}}>
{Ic ? <Ic size={18} color={color}/> : <span style={{fontSize:17, color}}>{icon}</span>}
</span>
<span style={{color, fontFamily:'monospace', fontSize:13}}>{label}</span>
</button>
);
};
const Sheet = ({open, onClose, children}) => {
if (!open) return null;
const isDesktop = window.innerWidth >= 768;
if (isDesktop) return (
<div style={{ position:'fixed', inset:0, zIndex:4000, background:'rgba(0,0,0,0.6)',
display:'flex', alignItems:'center', justifyContent:'center', padding:24 }}
onClick={onClose}>
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.15)',
borderRadius:16, padding:'20px 0', width:'100%', maxWidth:380,
boxShadow:'0 24px 80px rgba(0,0,0,0.6)' }}
onClick={e=>e.stopPropagation()}>
{children}
</div>
</div>
);
return (
<div style={{ position:'fixed', inset:0, zIndex:4000, background:'rgba(0,0,0,0.6)' }}
onClick={onClose}>
<div style={{ position:'absolute', bottom:`calc(${NAV_H}px + env(safe-area-inset-bottom, 0px))`,
left:0, right:0, background:'#1a1a1e',
borderTop:'1px solid rgba(255,255,255,0.1)',
borderRadius:'16px 16px 0 0', paddingTop:8 }}
onClick={e=>e.stopPropagation()}>
<div style={{ width:36, height:4, background:'rgba(255,255,255,0.15)', borderRadius:2, margin:'0 auto 10px' }}/>
{children}
<div style={{height:'env(safe-area-inset-bottom, 0px)'}}/>
</div>
</div>
);
};
const mainNav = [
{ id:'dashboard', Icon:HomeIcon, label:'Home' },
{ id:'_apps', Icon:AppsIcon, label:'Apps' },
{ id:'admin', Icon:AdminIcon, label:'Einst.' },
{ id:'_mehr', Icon:MoreIcon, label:'Mehr' },
];
return (
<>
{/* Apps Sheet mit Gruppenüberschriften */}
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
<div style={{ paddingBottom:4 }}>
{getGroupedTools().map(([group, tools]) => (
<div key={group}>
<div style={{ color:'rgba(78,205,196,0.85)', fontSize:9, fontFamily:'monospace',
letterSpacing:2, padding:'12px 20px 5px' }}>{group.toUpperCase()}</div>
{tools.map(t => (
<MI key={t.id} Icon={t.Icon} label={t.label}
onClick={() => { setActive(t.id); setAppsOpen(false); }} />
))}
</div>
))}
</div>
</Sheet>
{/* Mehr Sheet */}
<Sheet open={menuOpen} onClose={()=>setMenuOpen(false)}>
{updateInfo?.hasUpdate && <MI Icon={UpdateIcon} label={`Update: ${updateInfo.latestVersion}`} onClick={()=>{setMenuOpen(false);onUpdateClick();}} color="#ffe66d"/>}
<div style={{height:1, background:'rgba(255,255,255,0.08)', margin:'4px 0'}}/>
<MI Icon={UserIcon} label={user?.username||''} onClick={()=>{}} color="rgba(255,255,255,0.35)"/>
<MI Icon={LogOutIcon} label="Ausloggen" onClick={()=>{onLogout();setMenuOpen(false);}} color="#ff6b9d"/>
</Sheet>
{/* Bottom Bar */}
<nav style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:3000,
background:'#0d0d0f', borderTop:'1px solid rgba(255,255,255,0.12)' }}>
<div style={{ display:'flex', height:NAV_H }}>
{mainNav.map(item => {
const isApps = item.id === '_apps';
const isMehr = item.id === '_mehr';
const isActive = isApps ? appsOpen : isMehr ? menuOpen : active === item.id;
return (
<button key={item.id}
onClick={() => {
if (isApps) { setAppsOpen(v=>!v); setMenuOpen(false); }
else if (isMehr) { setMenuOpen(v=>!v); setAppsOpen(false); }
else { setActive(item.id); setAppsOpen(false); setMenuOpen(false); }
}}
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' }}>
{isMehr && updateInfo?.hasUpdate && (
<span style={{ position:'absolute', top:6, right:'calc(50% - 16px)',
width:8, height:8, borderRadius:'50%', background:'#ffe66d',
boxShadow:'0 0 6px #ffe66d', display:'inline-block' }} />
)}
{isApps && unreadMsgs>0 && (
<span style={{ position:'absolute', top:6, right:'calc(50% - 16px)',
width:8, height:8, borderRadius:'50%', background:'#4ecdc4',
boxShadow:'0 0 6px #4ecdc4', display:'inline-block' }} />
)}
{(() => { const I = item.Icon; return I ? <I size={20} color={isActive ? '#4ecdc4' : 'rgba(255,255,255,0.45)'}/> : null; })()}
<span style={{ fontSize:9, fontFamily:'monospace', letterSpacing:0.3,
color: isActive ? '#4ecdc4' : 'rgba(255,255,255,0.4)' }}>{item.label}</span>
{isActive && !isApps && !isMehr && (
<span style={{ position:'absolute', top:0, left:'50%', transform:'translateX(-50%)',
width:24, height:2, background:'#4ecdc4', borderRadius:'0 0 3px 3px' }} />
)}
</button>
);
})}
</div>
<div style={{ height:'env(safe-area-inset-bottom, 0px)', background:'#0d0d0f' }}/>
</nav>
</>
);
}
// ── Dashboard Widgets ─────────────────────────────────────────────────────────
// ── QuickLinks Carousel (Mobile) ─────────────────────────────────────────────
function QuickLinksCarousel({ links }) {
const iconW = 58; const gap = 6;
const containerW = window.innerWidth - 28;
const perRow = Math.max(3, Math.floor((containerW + gap) / (iconW + gap)));
const pageSize = perRow * 2;
const pageCount = Math.ceil(links.length / pageSize);
const [page, setPage] = useState(0);
const scrollRef = useRef(null);
// Sync scroll position → page indicator
const onScroll = () => {
if (!scrollRef.current) return;
const p = Math.round(scrollRef.current.scrollLeft / containerW);
setPage(p);
};
const canLeft = page > 0;
const canRight = page < pageCount - 1;
const scrollTo = (p) => {
scrollRef.current?.scrollTo({ left: p * containerW, behavior:'smooth' });
};
if (pageCount <= 1) return (
<div style={{ display:'grid', gridTemplateColumns:`repeat(${perRow}, 1fr)`, gap }}>
{links.map(l => <QuickLinkIcon key={l.id} l={l}/>)}
</div>
);
return (
<div style={{ position:'relative' }}>
{/* Scroll container overflow hidden so nothing peeks */}
<div ref={scrollRef} onScroll={onScroll} style={{
display:'flex', overflowX:'auto', scrollSnapType:'x mandatory',
scrollbarWidth:'none', WebkitOverflowScrolling:'touch',
marginInline:-14, paddingInline:14,
}}>
{Array.from({length: pageCount}, (_,pi) => (
<div key={pi} style={{
display:'grid',
gridTemplateColumns:`repeat(${perRow}, 1fr)`,
gridTemplateRows:'repeat(2, auto)',
gap, flexShrink:0, scrollSnapAlign:'start',
width: containerW,
}}>
{links.slice(pi*pageSize, (pi+1)*pageSize).map(l => <QuickLinkIcon key={l.id} l={l}/>)}
</div>
))}
</div>
{/* Page dots */}
{pageCount > 1 && (
<div style={{ display:'flex', justifyContent:'center', gap:4, marginTop:6 }}>
{Array.from({length:pageCount}, (_,i) => (
<div key={i} onClick={() => scrollTo(i)} style={{
width: i===page ? 14 : 5, height:5, borderRadius:3, cursor:'pointer',
background: i===page ? '#4ecdc4' : 'rgba(255,255,255,0.2)',
transition:'all 0.2s',
}}/>
))}
</div>
)}
</div>
);
}
function QuickLinkIcon({ l }) {
return (
<a href={l.url} target="_blank" rel="noopener noreferrer"
style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:3,
padding:'4px 2px', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
textDecoration:'none', height:48, cursor:'pointer' }}>
{l.icon && l.icon.startsWith('http')
? <img src={l.icon} alt="" style={{width:16,height:16,objectFit:'contain'}} onError={e=>e.target.style.display='none'}/>
: <span style={{ fontSize:16 }}>{l.icon}</span>}
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace',
maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>
</a>
);
}
function QuickLinks({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [links, setLinks] = useState([]);
const [editMode, setEdit] = useState(false);
const [showForm, setForm] = useState(false);
const [form, setF] = useState({ title:'', url:'', icon:'🔗' });
const [editId, setEditId] = useState(null);
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
const getFavicon = url => {
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
catch { return null; }
};
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
let url = form.url.trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
// Auto favicon if user left default
const icon = form.icon === '🔗' ? (getFavicon(url) || '🔗') : form.icon;
try {
if (editId) {
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, icon, url} });
setLinks(p => p.map(l => l.id===editId ? u : l));
} else {
const n = await api('/dashboard/links', { body:{...form, icon, url} });
setLinks(p => [...p, n]);
}
toast(editId ? 'Aktualisiert' : 'Gespeichert');
setF({ title:'', url:'', icon:'🔗' }); setForm(false); setEditId(null);
} catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
if (!await confirm('Link wirklich löschen?')) return;
try { await api(`/dashboard/links/${id}`, { method:'DELETE' }); setLinks(p => p.filter(l => l.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message, 'error'); }
};
return (
<div style={{ ...S.card, marginBottom:10 }}>
<ConfirmDialog/>
<div style={{ marginBottom:10 }}>
<div style={S.head}>SCHNELLZUGRIFF</div>
</div>
{/* Link-Grid */}
{links.length === 0 && !editMode
? <div style={{ color:'rgba(255,255,255,0.5)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>Links können unter Einstellungen Dashboard hinzugefügt werden.</div>
: mobile
? <QuickLinksCarousel links={links}/>
: <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(58px,1fr))', gap:6 }}>
{links.map(l => (
<div key={l.id} style={{ position:'relative' }}>
<a href={l.url} target="_blank" rel="noopener noreferrer"
style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:3,
padding:'6px 4px', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
textDecoration:'none', height:58, cursor:'pointer' }}>
{l.icon && l.icon.startsWith('http')
? <img src={l.icon} alt="" style={{width:20,height:20,objectFit:'contain'}} onError={e=>e.target.style.display='none'}/>
: <span style={{ fontSize:18 }}>{l.icon}</span>}
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace',
maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>
</a>
</div>
))}
</div>
}
{/* Formular */}
{showForm && (
<div style={{ marginTop:12, padding:12, background:'rgba(0,0,0,0.25)', border:'1px solid rgba(255,255,255,0.06)', borderRadius:10 }}>
{/* Icon-Picker */}
<div style={{ marginBottom:10 }}>
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
{CONST_ICONS.map(ic => (
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
background: form.icon===ic ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
}}>{ic}</button>
))}
</div>
</div>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginBottom:10 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>TITEL</label>
<input value={form.title} onChange={e => setF(f => ({...f, title:e.target.value}))}
style={{ ...S.inp, fontSize:16 }} placeholder="Mein Link" />
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>URL</label>
<input value={form.url} onChange={e => setF(f => ({...f, url:e.target.value}))}
onKeyDown={e => e.key==='Enter' && save()}
style={{ ...S.inp, fontSize:16 }} placeholder="https://…" inputMode="url" autoCapitalize="none" />
</div>
</div>
<div style={{ display:'flex', gap:8 }}>
<button onClick={save} style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center' }}>{editId ? '✓ Aktualisieren' : '✓ Speichern'}</button>
<button onClick={() => { setForm(false); setEditId(null); setF({ title:'', url:'', icon:'🔗' }); }} style={{ ...S.btn('#ff6b9d'), flex:1, textAlign:'center' }}>Abbrechen</button>
</div>
</div>
)}
</div>
);
}
function TodoList({ toast }) {
const [items, setItems] = useState([]);
const [text, setText] = useState('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => { api('/dashboard/todos').then(setItems).catch(() => {}); }, []);
const add = async () => {
if (!text.trim()) return;
try { const n = await api('/dashboard/todos', { body:{ text:text.trim() } }); setItems(p => [...p, n]); setText(''); }
catch(e) { toast(e.message, 'error'); }
};
const toggle = async item => {
try { const u = await api(`/dashboard/todos/${item.id}`, { method:'PUT', body:{ done:!item.done } }); setItems(p => p.map(i => i.id===item.id ? u : i)); }
catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
try { await api(`/dashboard/todos/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id!==id)); }
catch(e) { toast(e.message, 'error'); }
};
const open = items.filter(i => !i.done);
const done = items.filter(i => i.done);
return (
<div style={{ ...S.card, marginBottom:10 }}>
<button onClick={()=>setIsOpen(v=>!v)} style={{width:'100%',background:'transparent',border:'none',
display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer',padding:0}}>
<div style={{...S.head, marginBottom:0}}>TO-DO{items.filter(i=>!i.done).length > 0
? <span style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,marginLeft:6}}>({items.filter(i=>!i.done).length})</span>
: null}</div>
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11,display:'inline-block',
transform:isOpen?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}></span>
</button>
{isOpen && <><div style={{ display:'flex', gap:6, marginBottom:10, marginTop:10 }}>
<input value={text} onChange={e => setText(e.target.value)}
onKeyDown={e => e.key==='Enter' && add()}
style={{ ...S.inp, flex:1, fontSize:16 }} placeholder="Neue Aufgabe…" />
<button onClick={add} style={{ ...S.btn('#4ecdc4'), padding:'0 16px', fontSize:20, lineHeight:1 }}>+</button>
</div>
<div style={{ maxHeight:220, overflowY:'auto' }}>
{open.length === 0 && done.length === 0 && (
<div style={{ color:'rgba(255,255,255,0.45)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>Noch keine Aufgaben.</div>
)}
{open.map(item => (
<div key={item.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'11px 0', borderBottom:'1px solid rgba(255,255,255,0.04)' }}>
<button onClick={() => toggle(item)} style={{
width:22, height:22, borderRadius:5, flexShrink:0, cursor:'pointer',
border:'1.5px solid rgba(78,205,196,0.5)', background:'transparent',
}} />
<span style={{ color:'rgba(255,255,255,0.8)', fontSize:13, fontFamily:'monospace', flex:1, lineHeight:1.4 }}>{item.text}</span>
<button onClick={() => del(item.id)} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)', cursor:'pointer', fontSize:18, padding:'0 2px', lineHeight:1 }}></button>
</div>
))}
{done.length > 0 && (
<>
<div style={{ ...S.head, padding:'10px 0 4px', marginBottom:0 }}>ERLEDIGT</div>
{done.map(item => (
<div key={item.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'9px 0', opacity:0.4 }}>
<button onClick={() => toggle(item)} style={{
width:22, height:22, borderRadius:5, flexShrink:0, cursor:'pointer',
border:'1.5px solid rgba(78,205,196,0.4)', background:'rgba(78,205,196,0.15)',
color:'#4ecdc4', fontSize:12, lineHeight:1,
}}></button>
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:13, fontFamily:'monospace', flex:1, textDecoration:'line-through' }}>{item.text}</span>
<button onClick={() => del(item.id)} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)', cursor:'pointer', fontSize:18 }}></button>
</div>
))}
</>
)}
</div></>}
</div>
);
}
function Notepad({ toast }) {
const [content, setContent] = useState('');
const [saved, setSaved] = useState(true);
const [saveTime, setSaveTime] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const tmr = useRef(null);
useEffect(() => {
api('/dashboard/note').then(d => { setContent(d.content || ''); setSaveTime(d.updated_at); }).catch(() => {});
}, []);
const change = val => {
setContent(val); setSaved(false); clearTimeout(tmr.current);
tmr.current = setTimeout(async () => {
try { const r = await api('/dashboard/note', { method:'PUT', body:{ content:val } }); setSaved(true); setSaveTime(r.updated_at); }
catch(e) { toast(e.message, 'error'); }
}, 900);
};
return (
<div style={{ ...S.card }}>
<button onClick={()=>setIsOpen(v=>!v)} style={{width:'100%',background:'transparent',border:'none',
display:'flex',alignItems:'center',gap:8,cursor:'pointer',padding:0}}>
<div style={{...S.head, marginBottom:0}}>NOTIZEN</div>
{content.length > 0 && (
<span style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10}}>({content.length} Zeichen)</span>
)}
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11,display:'inline-block',marginLeft:'auto',
transform:isOpen?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}></span>
</button>
{isOpen && <div style={{ color: saved ? 'rgba(78,205,196,0.5)' : 'rgba(255,230,109,0.6)',
fontSize:10, fontFamily:'monospace', marginTop:4, marginBottom:6, textAlign:'right' }}>
{saved ? (saveTime ? `${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : '✓') : '…'}
</div>}
{isOpen && <textarea value={content} onChange={e => change(e.target.value)}
placeholder="Notizen, IPs, Zugangsdaten…"
style={{ ...S.inp, width:'100%', minHeight:140, resize:'vertical', lineHeight:1.7, padding:'10px 12px', fontSize:14 }} />}
</div>
);
}
// ── Bestell-Statistik Widget ─────────────────────────────────────────────────
function BestellStats() {
const [stats, setStats] = useState(null);
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = () => {
if (loaded) return;
api('/dashboard/stats').then(s => { setStats(s); setLoaded(true); }).catch(() => {});
};
const toggle = () => { setOpen(v => !v); if (!open) load(); };
const Row = ({label, val, color='#fff'}) => (
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:12}}>{label}</span>
<span style={{color,fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>{val}</span>
</div>
);
return (
<div style={{...S.card,marginBottom:10}}>
<button onClick={toggle} style={{width:'100%',background:'transparent',border:'none',
display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer',padding:0}}>
<div style={{...S.head, marginBottom:0}}>3D-DRUCK STATISTIK</div>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11,display:'inline-block',
transform:open?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}></span>
</button>
{open && (
<div style={{marginTop:12}}>
{!stats ? (
<div style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:12}}>Lädt</div>
) : (
<>
<div style={{marginBottom:12}}>
<div style={{...S.head,marginBottom:6}}>BESTELLUNGEN</div>
<Row label="Gesamt" val={stats.totalOrders}/>
<Row label="Diesen Monat" val={stats.ordersThisMonth} color="#4ecdc4"/>
<Row label="Warteliste" val={stats.byStatus.warteliste||0} color="#ffe66d"/>
<Row label="In Arbeit" val={stats.byStatus.in_arbeit||0} color="#4ecdc4"/>
<Row label="Fertig (unbezahlt)" val={stats.byStatus.fertig||0} color="#6bcb77"/>
<Row label="Bezahlt" val={stats.bezahltOrders} color="#c084fc"/>
</div>
<div>
<div style={{...S.head,marginBottom:6}}>EINNAHMEN</div>
<Row label="Eingenommen (bezahlt)" val={`${stats.totalRevenue.toFixed(2)}`} color="#6bcb77"/>
<Row label="Offen (in Arbeit/Fertig)" val={`${stats.offeneRevenue.toFixed(2)}`} color="#ffe66d"/>
<Row label="Archiv-Einträge" val={stats.totalCalcs} color="rgba(255,255,255,0.5)"/>
</div>
</>
)}
</div>
)}
</div>
);
}
// ── Nachrichten Dashboard Widget ──────────────────────────────────────────────
function NachrichtenWidget({ unreadMsgs, setActive }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div style={{ ...S.card, marginBottom:10 }}>
<button onClick={() => setIsOpen(v=>!v)} style={{
width:'100%', background:'transparent', border:'none', cursor:'pointer',
display:'flex', alignItems:'center', padding:0, gap:8 }}>
<MessageIcon size={13} color="rgba(255,255,255,0.5)"/>
<span style={{ ...S.head, marginBottom:0, flex:1, textAlign:'left' }}>NACHRICHTEN</span>
{unreadMsgs > 0 && !isOpen && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9,
fontFamily:'monospace', padding:'2px 7px', fontWeight:700 }}>
{unreadMsgs} neu
</span>
)}
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:11,
transform:isOpen?'rotate(90deg)':'rotate(0)', transition:'transform 0.2s' }}></span>
</button>
{isOpen && (
<div style={{ marginTop:12, display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
<span style={{ color: unreadMsgs > 0 ? '#4ecdc4' : 'rgba(255,255,255,0.35)',
fontSize:12, fontFamily:'monospace' }}>
{unreadMsgs > 0
? `${unreadMsgs} ungelesene Nachricht${unreadMsgs !== 1 ? 'en' : ''}`
: 'Keine neuen Nachrichten'}
</span>
<button onClick={() => setActive('nachrichten')} style={{ ...S.btn('#4ecdc4', true), flexShrink:0 }}>
Öffnen
</button>
</div>
)}
</div>
);
}
// ── Pushover Settings ─────────────────────────────────────────────────────────
function PushoverSettings({ toast }) {
const [form, setForm] = useState({ user_key:'', app_token:'', retry: '', expire: '' });
const [busy, setBusy] = useState(false);
const [hasSaved, setHasSaved] = useState(false);
const [emergency,setEmergency]= useState(false);
useEffect(() => {
api('/tools/nachrichten/pushover')
.then(d => {
const v = { user_key: d.user_key||'', app_token: d.app_token||'',
retry: d.retry||'', expire: d.expire||'' };
setForm(v);
setHasSaved(!!(v.user_key && v.app_token));
setEmergency(!!(d.retry && d.expire));
}).catch(()=>{});
}, []);
const save = async () => {
if (!form.user_key.trim() || !form.app_token.trim()) { toast('Beide Felder ausfüllen', 'error'); return; }
setBusy(true);
try {
await api('/tools/nachrichten/pushover', { body: {
...form,
retry: emergency ? (parseInt(form.retry) || 60) : null,
expire: emergency ? (parseInt(form.expire) || 3600) : null,
}});
toast('Gespeichert'); setHasSaved(true);
} catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const test = async () => {
setBusy(true);
try { await api('/tools/nachrichten/pushover/test', { method:'POST', body:{ emergency: false } }); toast('Test gesendet ✓ (normale Priorität)'); }
catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const testEmergency = async () => {
setBusy(true);
try { await api('/tools/nachrichten/pushover/test', { method:'POST', body:{ emergency: true } }); toast('🚨 Emergency-Test gesendet quittiere in der Pushover-App!'); }
catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const clear = async () => {
try {
await api('/tools/nachrichten/pushover', { method:'DELETE' });
setForm({ user_key:'', app_token:'', retry:'', expire:'' });
setHasSaved(false); setEmergency(false);
toast('Pushover entfernt');
} catch(e) { toast(e.message, 'error'); }
};
return (
<div>
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace', marginBottom:12, lineHeight:1.7 }}>
Push-Benachrichtigungen via{' '}
<a href="https://pushover.net" target="_blank" rel="noopener noreferrer" style={{ color:'#4ecdc4' }}>pushover.net</a>.
Du brauchst einen Account und eine eigene App (kostenlos).
</p>
<Field label="USER KEY" value={form.user_key} onChange={e=>setForm(p=>({...p,user_key:e.target.value}))} />
<Field label="APP TOKEN" value={form.app_token} onChange={e=>setForm(p=>({...p,app_token:e.target.value}))} />
{/* Emergency / Wiederholung */}
<div style={{ marginBottom:14, background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8, padding:'10px 12px' }}>
<label style={{ display:'flex', alignItems:'center', gap:8, cursor:'pointer', marginBottom: emergency ? 10 : 0 }}>
<input type="checkbox" checked={emergency} onChange={e=>setEmergency(e.target.checked)}
style={{ width:14, height:14, accentColor:'#ff6b9d', cursor:'pointer' }}/>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11 }}>
🚨 Wiederholen bis Quittierung (Emergency-Priorität)
</span>
</label>
{emergency && (
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:6 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3, fontSize:9 }}>WIEDERHOLUNG (SEK)</label>
<input type="number" value={form.retry||60} min={30} max={3600}
onChange={e=>setForm(p=>({...p,retry:e.target.value}))}
style={{ ...S.inp, fontSize:13 }}/>
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:8, marginTop:2 }}>
min. 30s · z.B. 60 = jede Minute
</div>
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3, fontSize:9 }}>MAX. DAUER (SEK)</label>
<input type="number" value={form.expire||3600} min={60} max={86400}
onChange={e=>setForm(p=>({...p,expire:e.target.value}))}
style={{ ...S.inp, fontSize:13 }}/>
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:8, marginTop:2 }}>
z.B. 3600 = max. 1 Stunde
</div>
</div>
</div>
)}
</div>
<div style={{ display:'flex', gap:8 }}>
<button onClick={save} disabled={busy}
style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
{busy ? '…' : '✓ Speichern'}
</button>
{hasSaved && (
<button onClick={test} disabled={busy}
style={{ ...S.btn('#ffe66d', true), padding:'0 10px', opacity:busy?0.5:1, fontSize:10 }}>Test</button>
)}
{hasSaved && emergency && (
<button onClick={testEmergency} disabled={busy}
style={{ ...S.btn('#ff6b9d', true), padding:'0 10px', opacity:busy?0.5:1, fontSize:10 }}>🚨 Test</button>
)}
{hasSaved && (
<button onClick={clear}
style={{ ...S.btn('#ff6b9d', true), padding:'0 10px' }}></button>
)}
</div>
</div>
);
}
// ── Ideen-Board Modal ─────────────────────────────────────────────────────────
// ── Board ItemRow (außerhalb BoardModal damit Inputs stabil bleiben) ───────────
function BoardItemRow({ item, isAdmin, userId, editId, editForm, setEditId, setEditForm, onSave, onDelete, onPromote }) {
const isEditing = editId === item.id;
const canEdit = isAdmin || (item.user_id === userId && item.type === 'wish');
return (
<div style={{padding:'10px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
{isEditing ? (
<div>
<input value={editForm.title} onChange={e=>setEditForm(p=>({...p,title:e.target.value}))}
style={{...S.inp,marginBottom:6,fontSize:13}} autoFocus/>
<input value={editForm.description} onChange={e=>setEditForm(p=>({...p,description:e.target.value}))}
placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:12}}/>
<div style={{display:'flex',gap:6}}>
<button onClick={()=>onSave(item.id)} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}></button>
<button onClick={()=>setEditId(null)} style={{...S.btn('#ff6b9d',true),fontSize:11,padding:'5px 10px'}}></button>
</div>
</div>
) : (
<div style={{display:'flex',alignItems:'flex-start',gap:8}}>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',alignItems:'center',gap:5,flexWrap:'wrap'}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{item.title}</div>
{!!item.promoted_from_wish && (
<span style={{background:'rgba(255,230,109,0.1)',border:'1px solid rgba(255,230,109,0.25)',
borderRadius:5,padding:'1px 6px',color:'#ffe66d',fontSize:9,fontFamily:'monospace'}}> aus Wünschen</span>
)}
</div>
{item.description && <div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,marginTop:2}}>{item.description}</div>}
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,marginTop:3}}>
{item.author} · {item.created_at ? new Date(item.created_at.replace(' ','T')).toLocaleDateString('de-DE') : ''}
</div>
</div>
<div style={{display:'flex',gap:4,flexShrink:0}}>
{isAdmin && item.type==='wish' && (
<button onClick={()=>onPromote(item.id)} title="Zur Roadmap befördern"
style={{...S.btn('#ffe66d',true),padding:'3px 8px',fontSize:11}}></button>
)}
{canEdit && (
<>
<button onClick={()=>{setEditId(item.id);setEditForm({title:item.title,description:item.description||''});}}
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11}}></button>
<button onClick={()=>onDelete(item.id)} style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11}}></button>
</>
)}
</div>
</div>
)}
</div>
);
}
function BoardModal({ user, toast, onClose, onRead }) {
const isAdmin = user?.role === 'admin';
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ type:'wish', title:'', description:'' });
const [editId, setEditId] = useState(null);
const [editForm, setEditForm] = useState({ title:'', description:'' });
const isMobile = window.innerWidth < 768;
useEffect(()=>{
api('/dashboard/board').then(d=>{ setItems(d.items||[]); setLoading(false); }).catch(()=>setLoading(false));
api('/dashboard/board/read',{method:'POST'}).then(()=>onRead()).catch(()=>{});
},[]);
const add = async () => {
if (!form.title.trim()) { toast('Titel erforderlich','error'); return; }
try {
const r = await api('/dashboard/board',{body:form});
setItems(p=>[r,...p]); setForm({type:'wish',title:'',description:''});
toast('Hinzugefügt ✓');
} catch(e){ toast(e.message,'error'); }
};
const save = async id => {
try {
const r = await api(`/dashboard/board/${id}`,{method:'PUT',body:editForm});
setItems(p=>p.map(i=>i.id===id?r:i)); setEditId(null); toast('Gespeichert ✓');
} catch(e){ toast(e.message,'error'); }
};
const del = async id => {
try { await api(`/dashboard/board/${id}`,{method:'DELETE'}); setItems(p=>p.filter(i=>i.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const promote = async id => {
try {
const r = await api(`/dashboard/board/${id}/promote`,{method:'POST'});
setItems(p=>p.map(i=>i.id===id?r:i)); toast('Zur Roadmap befördert ✓');
} catch(e){ toast(e.message,'error'); }
};
const roadmap = items.filter(i=>i.type==='roadmap');
const wishes = items.filter(i=>i.type==='wish');
const rowProps = { isAdmin, userId:user?.id, editId, editForm, setEditId, setEditForm,
onSave:save, onDelete:del, onPromote:promote };
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,width:'100%',maxWidth:520,
border:'1px solid rgba(255,255,255,0.12)',display:'flex',flexDirection:'column',
maxHeight:isMobile?'85vh':'80vh',overflow:'hidden'}}>
<div style={{padding:'16px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,
display:'flex',justifyContent:'space-between',alignItems:'center',position:'relative'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,
position:'absolute',top:8,left:'50%',transform:'translateX(-50%)'}}/>}
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>📋 Ideen & Roadmap</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{overflowY:'auto',flex:1,padding:'0 20px'}}>
<div style={{padding:'14px 0 4px'}}>
<div style={{...S.head,marginBottom:6}}>🗺 ROADMAP{isAdmin&&<span style={{color:'rgba(255,255,255,0.3)',fontWeight:400}}> (Admin)</span>}</div>
{loading ? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Lädt</div>
: roadmap.length===0 ? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Einträge.</div>
: roadmap.map(i=><BoardItemRow key={i.id} item={i} {...rowProps}/>)}
{isAdmin && (
<div style={{marginTop:10}}>
<input value={form.type==='roadmap'?form.title:''} placeholder="+ Roadmap-Eintrag hinzufügen…"
onFocus={()=>setForm(f=>({...f,type:'roadmap'}))}
onChange={e=>setForm(f=>({...f,title:e.target.value,type:'roadmap'}))}
onKeyDown={e=>e.key==='Enter'&&form.type==='roadmap'&&add()}
style={{...S.inp,fontSize:13,marginBottom:form.type==='roadmap'&&form.title?6:0}}/>
{form.type==='roadmap'&&form.title&&(
<div style={{display:'flex',gap:6}}>
<input value={form.description} placeholder="Beschreibung (optional)"
onChange={e=>setForm(f=>({...f,description:e.target.value}))}
style={{...S.inp,fontSize:12,flex:1}}/>
<button onClick={add} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>+</button>
</div>
)}
</div>
)}
</div>
<div style={{padding:'14px 0 20px',borderTop:'1px solid rgba(255,255,255,0.06)',marginTop:8}}>
<div style={{...S.head,marginBottom:2}}>💡 WÜNSCHE & IDEEN</div>
{isAdmin && <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginBottom:8}}> befördert Wunsch zur Roadmap</div>}
{loading ? null : wishes.length===0
? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Wünsche.</div>
: wishes.map(i=><BoardItemRow key={i.id} item={i} {...rowProps}/>)}
<div style={{marginTop:10}}>
<input value={form.type==='wish'?form.title:''} placeholder="+ Wunsch oder Idee einreichen…"
onFocus={()=>setForm(f=>({...f,type:'wish'}))}
onChange={e=>setForm(f=>({...f,title:e.target.value,type:'wish'}))}
onKeyDown={e=>e.key==='Enter'&&form.type==='wish'&&add()}
style={{...S.inp,fontSize:13,marginBottom:form.type==='wish'&&form.title?6:0}}/>
{form.type==='wish'&&form.title&&(
<div style={{display:'flex',gap:6}}>
<input value={form.description} placeholder="Beschreibung (optional)"
onChange={e=>setForm(f=>({...f,description:e.target.value}))}
style={{...S.inp,fontSize:12,flex:1}}/>
<button onClick={add} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>+</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
// ── Push-Zeitplaner Widget ────────────────────────────────────────────────────
function PushScheduler({ toast }) {
const [isOpen, setIsOpen] = useState(false);
const [schedules, setSchedules] = useState([]);
const [msg, setMsg] = useState('');
const [dt, setDt] = useState('');
const [editId, setEditId] = useState(null);
const [editMsg, setEditMsg] = useState('');
const [editDt, setEditDt] = useState('');
const [hasPushover,setHasPushover]= useState(null); // null=unbekannt
useEffect(() => {
if (!isOpen) return;
api('/dashboard/push-schedules').then(setSchedules).catch(()=>{});
if (hasPushover === null)
api('/tools/nachrichten/pushover').then(d=>setHasPushover(!!(d.user_key&&d.app_token))).catch(()=>{});
}, [isOpen]);
// Vorauswahl: aktuelle Lokalzeit
const defaultDt = () => {
const d = new Date();
const p = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
};
const fmtDt = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?s:d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); };
const add = async () => {
if (!msg.trim()) { toast('Nachricht eingeben','error'); return; }
if (!dt) { toast('Datum und Uhrzeit wählen','error'); return; }
try {
const r = await api('/dashboard/push-schedules',{body:{message:msg.trim(),scheduled_at:dt}});
setSchedules(p=>[...p,r].sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at)));
setMsg(''); setDt(''); toast('Erinnerung gespeichert ✓');
} catch(e){ toast(e.message,'error'); }
};
const del = async id => {
try { await api(`/dashboard/push-schedules/${id}`,{method:'DELETE'}); setSchedules(p=>p.filter(s=>s.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const saveEdit = async () => {
try {
const r = await api(`/dashboard/push-schedules/${editId}`,{method:'PUT',body:{message:editMsg,scheduled_at:editDt}});
setSchedules(p=>p.map(s=>s.id===editId?r:s).sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at)));
setEditId(null); toast('Gespeichert ✓');
} catch(e){ toast(e.message,'error'); }
};
return (
<div style={{...S.card, marginBottom:10}}>
<button onClick={()=>setIsOpen(v=>!v)} style={{
width:'100%',background:'transparent',border:'none',cursor:'pointer',
display:'flex',alignItems:'center',padding:0,gap:8}}>
<span style={{fontSize:13}}>🔔</span>
<span style={{...S.head,marginBottom:0,flex:1,textAlign:'left'}}>PUSH-ERINNERUNGEN</span>
{schedules.length>0&&!isOpen&&(
<span style={{color:'rgba(78,205,196,0.7)',fontFamily:'monospace',fontSize:9}}>{schedules.length} aktiv</span>
)}
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11,
transform:isOpen?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}></span>
</button>
{isOpen && (
<div style={{marginTop:12}}>
{hasPushover===false && (
<div style={{background:'rgba(255,230,109,0.07)',border:'1px solid rgba(255,230,109,0.2)',
borderRadius:7,padding:'8px 10px',color:'#ffe66d',fontFamily:'monospace',fontSize:10,
marginBottom:10,lineHeight:1.6}}>
Kein Pushover eingerichtet. Bitte erst unter Einstellungen Profil Pushover konfigurieren.
</div>
)}
{/* Neue Erinnerung */}
<div style={{marginBottom:14}}>
<input value={msg} onChange={e=>setMsg(e.target.value)}
placeholder="Nachricht…" style={{...S.inp,marginBottom:6,fontSize:13}}
onKeyDown={e=>e.key==='Enter'&&add()}/>
<div style={{display:'flex',gap:6}}>
<input type="datetime-local" value={dt||defaultDt()}
onChange={e=>setDt(e.target.value)}
style={{...S.inp,flex:1,fontSize:12,colorScheme:'dark'}}/>
<button onClick={add} style={{...S.btn('#4ecdc4'),padding:'0 14px',flexShrink:0}}>+</button>
</div>
</div>
{/* Liste */}
{schedules.length===0 ? (
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Keine aktiven Erinnerungen.</div>
) : schedules.map(s=>(
<div key={s.id} style={{padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
{editId===s.id ? (
<div>
<input value={editMsg} onChange={e=>setEditMsg(e.target.value)}
style={{...S.inp,marginBottom:6,fontSize:13}} autoFocus/>
<div style={{display:'flex',gap:6}}>
<input type="datetime-local" value={editDt} onChange={e=>setEditDt(e.target.value)}
style={{...S.inp,flex:1,fontSize:12,colorScheme:'dark'}}/>
<button onClick={saveEdit} style={{...S.btn('#4ecdc4'),padding:'0 10px'}}></button>
<button onClick={()=>setEditId(null)} style={{...S.btn('#ff6b9d',true),padding:'0 10px'}}></button>
</div>
</div>
) : (
<div style={{display:'flex',alignItems:'center',gap:8}}>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{s.message}</div>
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,marginTop:2}}>
🕐 {fmtDt(s.scheduled_at)}
</div>
</div>
<button onClick={()=>{setEditId(s.id);setEditMsg(s.message);setEditDt(s.scheduled_at?.slice(0,16)||'');}}
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11,flexShrink:0}}></button>
<button onClick={()=>del(s.id)}
style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11,flexShrink:0}}></button>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── Changelog Modal ───────────────────────────────────────────────────────────
function ChangelogModal({ user, toast, onClose, onRead }) {
const isAdmin = user?.role === 'admin';
const [entries, setEntries] = useState([]);
const [form, setForm] = useState({ version:'', title:'', body:'' });
const [busy, setBusy] = useState(false);
const [boardItems, setBoardItems] = useState([]);
const [toDelete, setToDelete] = useState(new Set()); // ids to delete on submit
const isMobile = window.innerWidth < 768;
useEffect(()=>{
api('/dashboard/changelog').then(setEntries).catch(()=>{});
api('/dashboard/changelog/read',{method:'POST'}).then(()=>{ if(onRead) onRead(); }).catch(()=>{});
if (user?.role==='admin') {
api('/dashboard/board').then(d=>setBoardItems(d.items||[])).catch(()=>{});
}
},[]);
const toggleDelete = id => setToDelete(p => {
const n = new Set(p);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
const add = async () => {
if (!form.version.trim() || !form.title.trim()) { toast('Stichwort und Titel erforderlich','error'); return; }
setBusy(true);
try {
const r = await api('/dashboard/changelog',{body:{
...form,
build_time: typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : null,
}});
setEntries(p=>[r,...p]);
// Markierte Board-Items löschen
if (toDelete.size > 0) {
await Promise.all([...toDelete].map(id => api(`/dashboard/board/${id}`,{method:'DELETE'}).catch(()=>{})));
setBoardItems(p => p.filter(i => !toDelete.has(i.id)));
setToDelete(new Set());
}
setForm({version:'',title:'',body:''});
toast('Eintrag hinzugefügt ✓');
} catch(e){ toast(e.message,'error'); }
setBusy(false);
};
const del = async id => {
try { await api(`/dashboard/changelog/${id}`,{method:'DELETE'}); setEntries(p=>p.filter(e=>e.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const fmtDate = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?'':d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}); };
const roadmap = boardItems.filter(i=>i.type==='roadmap');
const wishes = boardItems.filter(i=>i.type==='wish');
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth: isAdmin ? 860 : 540,
border:'1px solid rgba(255,255,255,0.12)',display:'flex',flexDirection:'column',
maxHeight:isMobile?'92vh':'84vh',overflow:'hidden'}}>
{/* Header */}
<div style={{padding:'16px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,
display:'flex',justifyContent:'space-between',alignItems:'center',position:'relative'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,
position:'absolute',top:8,left:'50%',transform:'translateX(-50%)'}}/>}
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>📝 Changelog</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{display:'flex',flex:1,overflow:'hidden',flexDirection:isMobile?'column':'row'}}>
{/* Linke Spalte: Formular + Einträge */}
<div style={{flex:1,overflowY:'auto',padding:'0 20px',minWidth:0}}>
{/* Admin-Formular */}
{isAdmin && (
<div style={{padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)'}}>
<div style={{...S.head,marginBottom:8}}>NEUER EINTRAG</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 2fr',gap:8,marginBottom:8}}>
<input value={form.version} onChange={e=>setForm(p=>({...p,version:e.target.value}))}
placeholder="Stichwort" style={{...S.inp,fontSize:12}}/>
<input value={form.title} onChange={e=>setForm(p=>({...p,title:e.target.value}))}
placeholder="Kurztitel" style={{...S.inp,fontSize:12}}/>
</div>
<textarea value={form.body} onChange={e=>setForm(p=>({...p,body:e.target.value}))}
placeholder="Beschreibung…"
rows={3} style={{...S.inp,resize:'vertical',fontSize:12,marginBottom:8,width:'100%',boxSizing:'border-box'}}/>
{toDelete.size>0 && (
<div style={{background:'rgba(255,107,157,0.07)',border:'1px solid rgba(255,107,157,0.2)',
borderRadius:6,padding:'6px 10px',marginBottom:8,color:'rgba(255,107,157,0.7)',
fontFamily:'monospace',fontSize:10}}>
🗑 {toDelete.size} ToDo{toDelete.size!==1?'s':''} wird beim Hinzufügen gelöscht
</div>
)}
<button onClick={add} disabled={busy}
style={{...S.btn('#4ecdc4'),width:'100%',textAlign:'center',padding:'9px 0',opacity:busy?0.5:1}}>
{busy?'…':'+ Eintrag hinzufügen'}
</button>
</div>
)}
{/* Einträge */}
<div style={{padding:'8px 0 20px'}}>
{entries.length===0 ? (
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11,padding:'20px 0',textAlign:'center'}}>
Noch keine Changelog-Einträge.
</div>
) : entries.map(e=>(
<div key={e.id} style={{padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<div style={{display:'flex',alignItems:'flex-start',justifyContent:'space-between',gap:10,marginBottom:4}}>
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'wrap'}}>
<span style={{
background:'rgba(78,205,196,0.12)',border:'1px solid rgba(78,205,196,0.25)',
borderRadius:5,padding:'2px 8px',color:'#4ecdc4',
fontFamily:'monospace',fontSize:11,fontWeight:700,flexShrink:0
}}>{e.version}</span>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{e.title}</span>
</div>
<div style={{display:'flex',alignItems:'center',gap:6,flexShrink:0}}>
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>
{e.build_time || fmtDate(e.created_at)}
</span>
{isAdmin && (
<button onClick={()=>del(e.id)}
style={{background:'transparent',border:'none',cursor:'pointer',
color:'rgba(255,107,157,0.5)',fontSize:13,padding:'0 2px',lineHeight:1}}></button>
)}
</div>
</div>
{e.body && (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,
lineHeight:1.7,whiteSpace:'pre-wrap',paddingLeft:4}}>
{e.body}
</div>
)}
</div>
))}
</div>
</div>
{/* Rechte Spalte: Board-Items zum Abhaken (nur Admin, nur wenn vorhanden) */}
{isAdmin && boardItems.length>0 && (
<div style={{width: isMobile?'100%':240,flexShrink:0,borderLeft:isMobile?'none':'1px solid rgba(255,255,255,0.07)',
borderTop:isMobile?'1px solid rgba(255,255,255,0.07)':'none',
overflowY:'auto',padding:'14px 16px',background:'rgba(0,0,0,0.15)'}}>
<div style={{...S.head,marginBottom:8}}>TODOS MITERLEDIGEN</div>
<p style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginBottom:12,lineHeight:1.6}}>
Markierte Einträge werden beim Hinzufügen des Changelogs gelöscht.
</p>
{roadmap.length>0 && (
<>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1,marginBottom:6}}>🗺 ROADMAP</div>
{roadmap.map(item=>(
<label key={item.id} style={{display:'flex',alignItems:'flex-start',gap:8,
padding:'6px 0',borderBottom:'1px solid rgba(255,255,255,0.04)',cursor:'pointer'}}>
<input type="checkbox" checked={toDelete.has(item.id)} onChange={()=>toggleDelete(item.id)}
style={{marginTop:2,accentColor:'#4ecdc4',flexShrink:0}}/>
<div>
<div style={{color: toDelete.has(item.id)?'rgba(255,255,255,0.35)':'rgba(255,255,255,0.7)',
fontFamily:'monospace',fontSize:11,
textDecoration: toDelete.has(item.id)?'line-through':'none'}}>
{item.title}
</div>
{item.description&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9}}>{item.description}</div>}
</div>
</label>
))}
</>
)}
{wishes.length>0 && (
<div style={{marginTop: roadmap.length>0?12:0}}>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1,marginBottom:6}}>💡 WÜNSCHE</div>
{wishes.map(item=>(
<label key={item.id} style={{display:'flex',alignItems:'flex-start',gap:8,
padding:'6px 0',borderBottom:'1px solid rgba(255,255,255,0.04)',cursor:'pointer'}}>
<input type="checkbox" checked={toDelete.has(item.id)} onChange={()=>toggleDelete(item.id)}
style={{marginTop:2,accentColor:'#ffe66d',flexShrink:0}}/>
<div>
<div style={{color: toDelete.has(item.id)?'rgba(255,255,255,0.35)':'rgba(255,255,255,0.7)',
fontFamily:'monospace',fontSize:11,
textDecoration: toDelete.has(item.id)?'line-through':'none'}}>
{item.title}
</div>
{item.description&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9}}>{item.description}</div>}
</div>
</label>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, unreadChangelog=0, onChangelogRead, user }) {
const [now, setNow] = useState(new Date());
const [showBoard, setShowBoard] = useState(false);
const [showChangelog, setShowChangelog] = useState(false);
useEffect(() => { const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []);
return (
<div style={{ padding: mobile ? '14px 12px 90px' : '36px 44px', maxWidth:1100 }}>
{showBoard && <BoardModal user={user} toast={toast} onClose={()=>setShowBoard(false)} onRead={onBoardRead}/>}
{showChangelog && <ChangelogModal user={user} toast={toast} onClose={()=>setShowChangelog(false)} onRead={onChangelogRead}/>}
<div style={{ marginBottom:16, display:'flex', alignItems:'flex-start', justifyContent:'space-between' }}>
<div>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile ? 17 : 22, margin:0 }}>Dashboard</h1>
{unreadChangelog > 0 && user?.role !== 'admin' && (
<button onClick={()=>setShowChangelog(true)} style={{
background:'linear-gradient(135deg,rgba(78,205,196,0.15),rgba(78,205,196,0.05))',
border:'1px solid rgba(78,205,196,0.3)', borderRadius:20,
color:'#4ecdc4', cursor:'pointer', fontFamily:'monospace',
fontSize:10, padding:'3px 10px', letterSpacing:0.5,
animation:'pulse 2s infinite',
}}> Was ist neu?</button>
)}
</div>
<p style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginTop:3 }}>
{now.toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })}
{' · '}
{now.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}
</p>
</div>
<button onClick={() => window.location.reload()}
title="Seite neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14,
fontFamily:'monospace', marginTop:2, flexShrink:0 }}></button>
</div>
<QuickLinks toast={toast} mobile={mobile} />
<PushScheduler toast={toast}/>
<CalendarWidget/>
<BestellStats/>
<TodoList toast={toast} />
<Notepad toast={toast} />
<div style={{ marginTop:24, display:'flex', alignItems:'center', justifyContent:'center', gap:12 }}>
<button onClick={()=>setShowChangelog(true)} style={{
background:'transparent', border:'none', cursor:'pointer',
color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace',
letterSpacing:1, padding:0, textDecoration:'underline dotted',
textUnderlineOffset:3 }}>
{typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__
? `Letzte Versionierung: ${__BUILD_TIME__}` : 'Changelog'}
</button>
<button onClick={()=>setShowBoard(true)} style={{
position:'relative', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
color:'rgba(255,255,255,0.45)', cursor:'pointer',
padding:'4px 10px', fontSize:9, fontFamily:'monospace', letterSpacing:1 }}>
📋 ToDo
{unreadBoard > 0 && (
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
)}
{unreadPromoted > 0 && unreadBoard === 0 && (
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
borderRadius:'50%', background:'#ffe66d', boxShadow:'0 0 5px #ffe66d' }}/>
)}
{unreadPromoted > 0 && unreadBoard > 0 && (
<span style={{ position:'absolute', top:-3, left:-3, width:7, height:7,
borderRadius:'50%', background:'#ffe66d', boxShadow:'0 0 5px #ffe66d' }}/>
)}
</button>
</div>
</div>
);
}
// ── Admin Panel ───────────────────────────────────────────────────────────────
// Sec muss außerhalb von AdminPanel definiert sein damit Inputs den Fokus behalten
const Sec = ({title, children}) => (
<div style={{...S.card, marginBottom:12}}>
<div style={S.head}>{title}</div>
{children}
</div>
);
// ── Account-Löschung ─────────────────────────────────────────────────────────
function DeleteAccountSection({ toast, user }) {
const [pw, setPw] = useState('');
const [busy, setBusy] = useState(false);
const { confirm, ConfirmDialog } = useConfirm();
const doDelete = async () => {
if (!pw) { toast('Passwort eingeben', 'error'); return; }
if (!await confirm(`Account "${user.username}" und alle Daten wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
setBusy(true);
try {
await api('/auth/delete-account', { method:'DELETE', body:{ password:pw } });
localStorage.removeItem('sk_token');
window.location.reload();
} catch(e) { toast(e.message, 'error'); } finally { setBusy(false); }
};
return (
<div>
<ConfirmDialog/>
<Field label="PASSWORT ZUR BESTÄTIGUNG" type="password" value={pw} onChange={e=>setPw(e.target.value)}/>
<button onClick={doDelete} disabled={busy} style={{
...S.btn('#ff6b9d'), width:'100%', textAlign:'center', padding:'11px 0',
opacity: busy ? 0.6 : 1,
}}>
{busy ? '…' : '✕ Account unwiderruflich löschen'}
</button>
</div>
);
}
// ── QuickLinks Einstellungen ─────────────────────────────────────────────────
function QuickLinksSettings({ toast }) {
const [links, setLinks] = useState([]);
const [showForm, setForm] = useState(false);
const [form, setF] = useState({ title:'', url:'', icon:'🔗' });
const [editId, setEditId] = useState(null);
const dragRef = useRef(null);
const touchRef = useRef({});
const { confirm, ConfirmDialog } = useConfirm();
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
const getFavicon = url => {
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
catch { return null; }
};
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
let url = form.url.trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
const icon = form.icon === '🔗' ? (getFavicon(url) || '🔗') : form.icon;
try {
if (editId) {
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, icon, url} });
setLinks(p => p.map(l => l.id===editId ? u : l));
} else {
const n = await api('/dashboard/links', { body:{...form, icon, url} });
setLinks(p => [...p, n]);
}
toast(editId ? 'Aktualisiert' : 'Gespeichert');
setF({ title:'', url:'', icon:'🔗' }); setForm(false); setEditId(null);
} catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
if (!await confirm('Link wirklich löschen?')) return;
try { await api(`/dashboard/links/${id}`, { method:'DELETE' }); setLinks(p => p.filter(l => l.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message, 'error'); }
};
const reorder = async (newLinks) => {
setLinks(newLinks);
try { await api('/dashboard/links-order', { method:'PUT', body:{ ids: newLinks.map(l=>l.id) } }); }
catch(e) { toast(e.message,'error'); }
};
// Desktop drag handlers
const onDragStart = (e, id) => { dragRef.current = id; e.dataTransfer.effectAllowed = 'move'; };
const onDragOver = (e, id) => {
e.preventDefault();
if (dragRef.current === id) return;
const from = links.findIndex(l => l.id === dragRef.current);
const to = links.findIndex(l => l.id === id);
if (from < 0 || to < 0) return;
const next = [...links]; next.splice(to, 0, next.splice(from, 1)[0]);
setLinks(next);
};
const onDrop = () => reorder(links);
// Touch handlers (long press + drag)
const onTouchStart = (e, id) => {
touchRef.current = { id, startY: e.touches[0].clientY, timer: setTimeout(() => {
touchRef.current.active = true;
}, 300) };
};
const onTouchMove = (e) => {
if (!touchRef.current.active) return;
e.preventDefault();
const y = e.touches[0].clientY;
const els = document.elementsFromPoint(e.touches[0].clientX, y);
const target = els.find(el => el.dataset?.linkId);
if (!target) return;
const targetId = parseInt(target.dataset.linkId);
if (targetId === touchRef.current.id) return;
const from = links.findIndex(l => l.id === touchRef.current.id);
const to = links.findIndex(l => l.id === targetId);
if (from < 0 || to < 0) return;
const next = [...links]; next.splice(to, 0, next.splice(from, 1)[0]);
setLinks(next);
};
const onTouchEnd = () => {
clearTimeout(touchRef.current.timer);
if (touchRef.current.active) reorder(links);
touchRef.current = {};
};
return (
<div>
<ConfirmDialog/>
{/* Link-Liste */}
{links.length > 0 && (
<div style={{ marginBottom:12 }}>
{links.map(l => (
<div key={l.id}
data-link-id={l.id}
draggable
onDragStart={e => onDragStart(e, l.id)}
onDragOver={e => onDragOver(e, l.id)}
onDrop={onDrop}
onTouchStart={e => onTouchStart(e, l.id)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',
cursor:'grab', userSelect:'none' }}>
<span style={{ color:'rgba(255,255,255,0.2)', fontSize:14, flexShrink:0, cursor:'grab' }}></span>
<span style={{ fontSize:18, width:24, textAlign:'center', flexShrink:0 }}>
{l.icon && l.icon.startsWith('http')
? <img src={l.icon} alt="" style={{width:18,height:18,objectFit:'contain'}} onError={e=>e.target.style.display='none'}/>
: l.icon}
</span>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13 }}>{l.title}</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.url}</div>
</div>
<div style={{ display:'flex', gap:5, flexShrink:0 }}>
<button onClick={() => { setF({ title:l.title, url:l.url, icon:l.icon }); setEditId(l.id); setForm(true); }}
style={S.btn('#4ecdc4', true)}></button>
<button onClick={() => del(l.id)} style={S.btn('#ff6b9d', true)}></button>
</div>
</div>
))}
</div>
)}
{/* Formular */}
{showForm ? (
<div style={{ background:'rgba(0,0,0,0.2)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:10, padding:12 }}>
<div style={{ marginBottom:10 }}>
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
{['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'].map(ic => (
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
width:34, height:34, borderRadius:7, fontSize:17, cursor:'pointer',
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
background: form.icon===ic ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
}}>{ic}</button>
))}
</div>
</div>
<Field label="TITEL" value={form.title} onChange={e => setF(f => ({...f, title:e.target.value}))} placeholder="Mein Link"/>
<Field label="URL" value={form.url} onChange={e => setF(f => ({...f, url:e.target.value}))} placeholder="https://…" autoCapitalize="none"/>
<div style={{ display:'flex', gap:8, marginTop:4 }}>
<button onClick={save} style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0' }}>{editId ? '✓ Aktualisieren' : '✓ Speichern'}</button>
<button onClick={() => { setForm(false); setEditId(null); setF({ title:'', url:'', icon:'🔗' }); }}
style={{ ...S.btn('#ff6b9d'), flex:1, textAlign:'center', padding:'10px 0' }}>Abbrechen</button>
</div>
</div>
) : (
<button onClick={() => { setForm(true); setEditId(null); setF({ title:'', url:'', icon:'🔗' }); }}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
+ Link hinzufügen
</button>
)}
</div>
);
}
// ── Benutzerverwaltung ────────────────────────────────────────────────────────
// ── Login-Sicherheit Admin ────────────────────────────────────────────────────
function SecuritySettingsAdmin({ toast }) {
const [s, setS] = useState({ login_max_attempts:'5', login_lockout_minutes:'30' });
const [locked, setLocked] = useState([]);
const [busy, setBusy] = useState(false);
const load = () => {
api('/admin/security-settings').then(setS).catch(()=>{});
api('/admin/lockouts').then(setLocked).catch(()=>{});
};
useEffect(()=>{ load(); },[]);
const save = async () => {
setBusy(true);
try { await api('/admin/security-settings',{method:'PUT',body:s}); toast('Gespeichert ✓'); }
catch(e) { toast(e.message,'error'); }
setBusy(false);
};
const unlock = async uid => {
try { await api(`/admin/lockouts/${uid}`,{method:'DELETE'}); toast('Sperre aufgehoben'); setLocked(p=>p.filter(l=>l.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const fmtDate = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?'':d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); };
return (
<div>
<Sec title="LOGIN-SCHUTZ EINSTELLUNGEN">
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, marginBottom:12 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>MAX. FEHLVERSUCHE</label>
<input type="number" min="1" max="20" value={s.login_max_attempts}
onChange={e=>setS(p=>({...p,login_max_attempts:e.target.value}))}
style={{ ...S.inp, fontSize:15 }}/>
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SPERRZEIT (MINUTEN)</label>
<input type="number" min="1" max="1440" value={s.login_lockout_minutes}
onChange={e=>setS(p=>({...p,login_lockout_minutes:e.target.value}))}
style={{ ...S.inp, fontSize:15 }}/>
</div>
</div>
<button onClick={save} disabled={busy}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
{busy?'…':'✓ Speichern'}
</button>
</Sec>
<Sec title={`GESPERRTE KONTEN ${locked.length>0?`(${locked.length})`:''}`}>
{locked.length===0 ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>
Keine gesperrten Konten
</div>
) : locked.map(u => (
<div key={u.id} style={{ display:'flex', alignItems:'center', gap:10,
padding:'10px 0', borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>
🔒 {u.username}
</div>
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, marginTop:3, lineHeight:1.7 }}>
{u.failed_attempts} Fehlversuch{u.failed_attempts!==1?'e':''} ·
Gesperrt bis: {fmtDate(u.locked_until)}
</div>
{u.last_failed_at && (
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9 }}>
Letzter Versuch: {fmtDate(u.last_failed_at)}
</div>
)}
{u.ips?.length > 0 && (
<div style={{ marginTop:4, display:'flex', flexWrap:'wrap', gap:4 }}>
{u.ips.map((ip,i) => (
<span key={i} style={{ background:'rgba(255,107,157,0.1)', border:'1px solid rgba(255,107,157,0.2)',
borderRadius:5, padding:'1px 7px', color:'rgba(255,107,157,0.7)',
fontFamily:'monospace', fontSize:9 }}>{ip}</span>
))}
</div>
)}
</div>
<button onClick={()=>unlock(u.id)}
style={{ ...S.btn('#4ecdc4', true), flexShrink:0, fontSize:11 }}>
🔓 Entsperren
</button>
</div>
))}
</Sec>
</div>
);
}
function UserManagement({ toast }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ username:'', password:'', role:'user' });
const [resetId, setResetId] = useState(null);
const [resetPw, setResetPw] = useState('');
const [delId, setDelId] = useState(null);
const { confirm, ConfirmDialog } = useConfirm();
const load = () => {
setLoading(true);
api('/admin/users').then(setUsers).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false));
};
useEffect(()=>{ load(); },[]);
const create = async () => {
if (!form.username.trim()||!form.password) { toast('Alle Felder ausfüllen','error'); return; }
try {
await api('/admin/users',{body:form});
toast(`Benutzer "${form.username}" angelegt`);
setForm({username:'',password:'',role:'user'}); load();
} catch(e) { toast(e.message,'error'); }
};
const del = async id => {
if (!await confirm('Benutzer und alle seine Daten wirklich löschen?')) return;
try { await api(`/admin/users/${id}`,{method:'DELETE'}); toast('Gelöscht'); load(); }
catch(e) { toast(e.message,'error'); }
};
const resetPassword = async () => {
if (!resetPw||resetPw.length<8) { toast('Mind. 8 Zeichen','error'); return; }
try {
await api(`/admin/users/${resetId}/reset-password`,{method:'PUT',body:{newPassword:resetPw}});
toast('Passwort zurückgesetzt'); setResetId(null); setResetPw('');
} catch(e) { toast(e.message,'error'); }
};
return (
<div>
<ConfirmDialog/>
{/* Neuen Benutzer anlegen */}
<Sec title="NEUEN BENUTZER ANLEGEN">
<Field label="BENUTZERNAME" value={form.username} onChange={e=>setForm(f=>({...f,username:e.target.value}))} autoCapitalize="none"/>
<Field label="PASSWORT (mind. 8 Zeichen)" type="password" value={form.password} onChange={e=>setForm(f=>({...f,password:e.target.value}))}/>
<div style={{marginBottom:12}}>
<label style={{...S.head,display:'block',marginBottom:4}}>ROLLE</label>
<select value={form.role} onChange={e=>setForm(f=>({...f,role:e.target.value}))} style={{...S.inp,fontSize:14}}>
<option value="user" style={{background:'#1a1a1e'}}>Benutzer</option>
<option value="admin" style={{background:'#1a1a1e'}}>Administrator</option>
</select>
</div>
<button onClick={create} style={{...S.btn('#4ecdc4'),width:'100%',textAlign:'center',padding:'11px 0'}}>
+ Benutzer anlegen
</button>
</Sec>
{/* Passwort-Reset Modal */}
{resetId && (
<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:360}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700,marginBottom:16}}>
Passwort zurücksetzen
</div>
<Field label="NEUES PASSWORT" type="password" value={resetPw} onChange={e=>setResetPw(e.target.value)}/>
<div style={{display:'flex',gap:8}}>
<button onClick={resetPassword} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0'}}> Setzen</button>
<button onClick={()=>{setResetId(null);setResetPw('');}} style={{...S.btn('#ff6b9d'),flex:1,textAlign:'center',padding:'10px 0'}}>Abbrechen</button>
</div>
</div>
</div>
)}
{/* Benutzerliste */}
<Sec title="BENUTZER">
{loading && <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
{users.map(u => (
<div key={u.id} style={{
padding:'12px 0', borderBottom:'1px solid rgba(255,255,255,0.06)',
}}>
{/* Zeile 1: Name + Badges */}
<div style={{display:'flex',alignItems:'flex-start',justifyContent:'space-between',gap:8,marginBottom:6}}>
<div style={{minWidth:0,flex:1}}>
<div style={{display:'flex',alignItems:'center',flexWrap:'wrap',gap:4,marginBottom:3}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{u.username}</span>
<span style={{
fontSize:9, fontFamily:'monospace',
color: u.role==='admin'?'#ffe66d':'#4ecdc4',
background: u.role==='admin'?'rgba(255,230,109,0.1)':'rgba(78,205,196,0.1)',
border:`1px solid ${u.role==='admin'?'rgba(255,230,109,0.3)':'rgba(78,205,196,0.3)'}`,
borderRadius:4, padding:'1px 6px',
}}>{u.role}</span>
{!!u.has_pushover && (
<span style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,107,157,0.8)',
background:'rgba(255,107,157,0.1)',border:'1px solid rgba(255,107,157,0.25)',
borderRadius:4,padding:'1px 6px'}}>🔔 Push</span>
)}
{!!u.hidden && (
<span style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,230,109,0.7)',
background:'rgba(255,230,109,0.08)',border:'1px solid rgba(255,230,109,0.2)',
borderRadius:4,padding:'1px 6px'}}>👁 versteckt</span>
)}
</div>
{u.last_active_at && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9}}>
{(() => {
const d = new Date(u.last_active_at.replace(' ','T'));
const diff = Math.floor((new Date() - d) / 60000);
if (diff < 2) return 'gerade eben';
if (diff < 60) return `vor ${diff} min`;
if (diff < 1440) return `vor ${Math.floor(diff/60)} h`;
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
})()}
</div>
)}
</div>
{/* Aktions-Buttons */}
<div style={{display:'flex',gap:4,flexShrink:0,flexWrap:'wrap',justifyContent:'flex-end'}}>
<button onClick={()=>{setResetId(u.id);setResetPw('');}}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🔑</button>
<button
onClick={async ()=>{
if (u.role==='admin') return;
try {
const r = await api(`/admin/users/${u.id}/hidden`,{method:'PUT'});
setUsers(p=>p.map(x=>x.id===u.id?{...x,hidden:r.hidden}:x));
toast(r.hidden ? 'Ausgeblendet' : 'Eingeblendet');
} catch(e){ toast(e.message,'error'); }
}}
disabled={u.role==='admin'}
title={u.hidden?'Einblenden':'Ausblenden'}
style={{
fontSize:13, cursor: u.role==='admin'?'not-allowed':'pointer',
padding:'4px 7px', borderRadius:6, border:'none',
background: u.hidden ? 'rgba(255,230,109,0.15)' : 'rgba(255,255,255,0.08)',
color: u.hidden ? '#ffe66d' : 'rgba(255,255,255,0.4)',
opacity: u.role==='admin' ? 0.3 : 1,
}}>👁</button>
<button onClick={()=>del(u.id)} style={{...S.btn('#ff6b9d',true),padding:'4px 7px'}}>
<TrashIcon size={13} color="#ff6b9d"/>
</button>
</div>
</div>
{/* Statistik */}
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{[
['◈ Archiv', u.archiv],
['📦 Bestellungen', u.bestellung],
['</> Codes', u.snippets_c],
['✓ Todos', u.todos],
['🔗 Links', u.links],
['📁 Dateien', u.files],
['📅 iCals', u.icals],
[`${u.noteLen} Zeichen`, null],
].map(([label, val]) => (
<span key={label} style={{
fontSize:10, fontFamily:'monospace',
color:'rgba(255,255,255,0.55)',
background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8, padding:'2px 8px',
}}>
{label}{val !== null ? `: ${val}` : ''}
</span>
))}
</div>
</div>
))}
</Sec>
</div>
);
}
// ── Sicherheit / Key-Management ───────────────────────────────────────────────
function SecuritySettings({ toast }) {
const [kp, setKp] = useState(null);
const [fingerprint, setFp] = useState('');
const [expPw, setExpPw] = useState('');
const [expPw2, setExpPw2] = useState('');
const [showExport, setShowExport] = useState(false);
const [impFile, setImpFile] = useState(null);
const [impPw, setImpPw] = useState('');
const [showImport, setShowImport] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
getOrCreateKeyPair()
.then(async k => {
setKp(k);
setFp(await getFingerprint(k));
})
.catch(() => {});
}, []);
const doExport = async () => {
if (!kp) return;
if (expPw.length < 8) { toast('Passwort mind. 8 Zeichen', 'error'); return; }
if (expPw !== expPw2) { toast('Passwörter stimmen nicht überein', 'error'); return; }
setBusy(true);
try {
const json = await exportEncrypted(kp, expPw);
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([json], { type:'application/json' })),
download: 'dickendock-key.json',
});
a.click(); URL.revokeObjectURL(a.href);
setExpPw(''); setExpPw2(''); setShowExport(false);
toast('Schlüssel exportiert ✓');
} catch { toast('Export fehlgeschlagen', 'error'); }
setBusy(false);
};
const doImport = async () => {
if (!impFile || !impPw) { toast('Datei und Passwort erforderlich', 'error'); return; }
setBusy(true);
try {
const text = await impFile.text();
const newKp = await importEncrypted(text, impPw);
const jwk = await getPublicKeyJwk(newKp);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
setKp(newKp);
setFp(await getFingerprint(newKp));
setImpFile(null); setImpPw(''); setShowImport(false);
toast('Schlüssel importiert ✓');
} catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const doReset = async () => {
if (!window.confirm(
'Neues Schlüsselpaar erzeugen?\n\n' +
'Alle bisherigen Nachrichten werden vom Server gelöscht und können nicht mehr gelesen werden. ' +
'Geräte mit dem alten Schlüssel können keine neuen Nachrichten mehr lesen.'
)) return;
setBusy(true);
try {
const newKp = await generateNewKeyPair();
const jwk = await getPublicKeyJwk(newKp);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
await api('/tools/nachrichten/my-messages', { method:'DELETE' });
toast('Neues Schlüsselpaar aktiv App wird neu geladen…');
setTimeout(() => window.location.reload(), 1500);
} catch(e) { toast(e.message, 'error'); setBusy(false); }
};
const FpChar = ({ c }) => (
<span style={{ background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.09)',
borderRadius:5, padding:'5px 8px', fontFamily:'monospace', fontSize:14, letterSpacing:2,
color: c === '-' ? 'rgba(255,255,255,0.2)' : '#4ecdc4' }}>{c}</span>
);
return (
<div>
{/* Fingerprint */}
<Sec title="SCHLÜSSEL-FINGERPRINT">
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Das ist der Fingerprint <strong style={{color:'rgba(255,255,255,0.65)'}}>deines eigenen</strong> Public Keys.
Dein Gesprächspartner sieht im Chat-Header deinen Fingerprint er soll ihn mit diesem hier vergleichen.
Stimmen beide überein, ist die Verbindung sicher.
</p>
{fingerprint ? (
<div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:8 }}>
{fingerprint.split('').map((c, i) => <FpChar key={i} c={c}/>)}
</div>
) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:11 }}>Wird geladen</div>
)}
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace', marginTop:6 }}>
SHA-256(PublicKey) · X25519
</div>
</Sec>
{/* Export */}
<Sec title="SCHLÜSSEL EXPORTIEREN">
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Exportiere deinen privaten Schlüssel verschlüsselt als Datei um ihn auf einem anderen Gerät zu nutzen.
</p>
{!showExport ? (
<button onClick={() => setShowExport(true)} disabled={!kp}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
Schlüssel exportieren
</button>
) : (
<div>
<Field label="EXPORT-PASSWORT (mind. 8 Zeichen)" type="password"
value={expPw} onChange={e => setExpPw(e.target.value)}/>
<Field label="PASSWORT BESTÄTIGEN" type="password"
value={expPw2} onChange={e => setExpPw2(e.target.value)}/>
<div style={{ display:'flex', gap:8 }}>
<button onClick={doExport} disabled={busy}
style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
{busy ? '…' : '↓ Herunterladen'}
</button>
<button onClick={() => { setShowExport(false); setExpPw(''); setExpPw2(''); }}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen</button>
</div>
</div>
)}
</Sec>
{/* Import */}
<Sec title="SCHLÜSSEL IMPORTIEREN">
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Importiere eine zuvor exportierte Schlüsseldatei. Der aktuelle Schlüssel wird ersetzt.
</p>
{!showImport ? (
<button onClick={() => setShowImport(true)}
style={{ ...S.btn('#ffe66d'), width:'100%', textAlign:'center', padding:'10px 0' }}>
Schlüssel importieren
</button>
) : (
<div>
<div style={{ marginBottom:12 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SCHLÜSSELDATEI (.json)</label>
<input type="file" accept=".json"
onChange={e => setImpFile(e.target.files?.[0] || null)}
style={{ ...S.inp, fontSize:13, padding:'9px 10px', cursor:'pointer' }}/>
</div>
<Field label="EXPORT-PASSWORT" type="password"
value={impPw} onChange={e => setImpPw(e.target.value)}/>
<div style={{ display:'flex', gap:8 }}>
<button onClick={doImport} disabled={busy || !impFile || !impPw}
style={{ ...S.btn('#ffe66d'), flex:1, textAlign:'center', padding:'10px 0',
opacity: busy || !impFile || !impPw ? 0.4 : 1 }}>
{busy ? '…' : '✓ Importieren'}
</button>
<button onClick={() => { setShowImport(false); setImpFile(null); setImpPw(''); }}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen</button>
</div>
</div>
)}
</Sec>
{/* Reset Danger Zone */}
<Sec title="SCHLÜSSEL ZURÜCKSETZEN">
<p style={{ color:'rgba(255,107,157,0.6)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Gefahrenzone: Erzeugt ein neues Schlüsselpaar und löscht alle deine Nachrichten vom Server.
Alte Nachrichten sind danach nicht mehr lesbar. Geräte mit dem alten Schlüssel werden ausgesperrt.
</p>
<button onClick={doReset} disabled={busy}
style={{ ...S.btn('#ff6b9d'), width:'100%', textAlign:'center', padding:'10px 0',
opacity: busy ? 0.5 : 1 }}>
{busy ? '…' : '✕ Neues Schlüsselpaar erzeugen'}
</button>
</Sec>
</div>
);
}
// Field außerhalb definiert → Tastatur bleibt beim Tippen erhalten
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
<div style={{ marginBottom:12 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>{label}</label>
<input type={type} value={value} onChange={onChange} placeholder={placeholder}
autoCapitalize={autoCapitalize}
style={{ ...S.inp, fontSize:16, padding:'11px 12px' }} />
</div>
);
function AdminPanel({ toast, mobile, user }) {
const [section, setSection] = useState('profil');
const [pw, setPw] = useState({ current:'', newPw:'', confirm:'' });
const [avatar, setAvatar] = useState(localStorage.getItem('dd_avatar') || null);
const [un, setUn] = useState({ newUsername:'', unPw:'' });
const [file, setFile] = useState(null);
const [busy, setBusy] = useState({ pw:false, un:false, restore:false });
const set = (key, val) => setBusy(b => ({...b, [key]:val}));
const changePw = async () => {
if (pw.newPw !== pw.confirm) { toast('Passwörter stimmen nicht überein', 'error'); return; }
if (pw.newPw.length < 8) { toast('Mindestens 8 Zeichen', 'error'); return; }
set('pw', true);
try { await api('/auth/change-password', { body:{ currentPassword:pw.current, newPassword:pw.newPw } }); toast('Passwort geändert'); setPw({ current:'', newPw:'', confirm:'' }); }
catch(e) { toast(e.message, 'error'); } finally { set('pw', false); }
};
const changeUn = async () => {
if (!un.newUsername.trim()) { toast('Benutzername eingeben', 'error'); return; }
set('un', true);
try {
await api('/auth/change-username', { body:{ newUsername:un.newUsername.trim(), password:un.unPw } });
toast('Benutzername geändert bitte neu einloggen');
setUn({ newUsername:'', unPw:'' });
setTimeout(() => { localStorage.removeItem('sk_token'); window.location.reload(); }, 1800);
} catch(e) { toast(e.message, 'error'); } finally { set('un', false); }
};
const backup = async () => {
try {
const blob = await api('/admin/backup');
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: `dickendock-backup-${new Date().toISOString().split('T')[0]}.db`
});
a.click(); URL.revokeObjectURL(a.href); toast('Backup heruntergeladen');
} catch(e) { toast(e.message, 'error'); }
};
const restore = async () => {
if (!file) { toast('Datei auswählen', 'error'); return; }
if (!window.confirm('⚠️ Aktuelle Datenbank wird ersetzt. Fortfahren?')) return;
set('restore', true);
try {
const f = new FormData(); f.append('database', file);
await api('/admin/restore', { method:'POST', body:f, isFile:true });
toast('Wiederhergestellt'); setFile(null);
} catch(e) { toast(e.message, 'error'); } finally { set('restore', false); }
};
return (
<div style={{ padding: mobile ? '14px 12px 90px' : '36px 44px', maxWidth:560 }}>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:16 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?17:22, margin:0 }}>Einstellungen</h1>
<button onClick={() => window.location.reload()} title="Seite neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>
</div>
<div style={{ display:'flex', gap:6, marginBottom:18, flexWrap:'wrap' }}>
{[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])]
.map(([k,l]) => (
<button key={k} onClick={()=>setSection(k)} style={{
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
border:`1px solid ${section===k?'rgba(78,205,196,0.5)':'rgba(255,255,255,0.15)'}`,
background: section===k?'rgba(78,205,196,0.12)':'transparent',
color: section===k?'#4ecdc4':'rgba(255,255,255,0.55)',
}}>{l}</button>
))}
</div>
{section === 'profil' && (
<div>
<Sec title="AVATAR">
<div style={{ display:'flex', alignItems:'center', gap:14, marginBottom:12 }}>
<div style={{ width:60, height:60, borderRadius:'50%', overflow:'hidden', flexShrink:0,
background:'linear-gradient(135deg,#ff6b9d,#4ecdc4)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
{avatar
? <img src={avatar} alt="Avatar" style={{width:'100%',height:'100%',objectFit:'cover'}}/>
: <UserIcon size={28} color="#0d0d0f"/>}
</div>
<div style={{flex:1}}>
<label style={{...S.head,display:'block',marginBottom:6}}>PROFILBILD</label>
<input type="file" accept="image/*" onChange={async e => {
const file = e.target.files?.[0]; if(!file) return;
const reader = new FileReader();
reader.onload = ev => {
const canvas = document.createElement('canvas');
const img = new Image(); img.onload = () => {
const size = 120;
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.beginPath(); ctx.arc(size/2,size/2,size/2,0,Math.PI*2);
ctx.clip();
const min = Math.min(img.width,img.height);
ctx.drawImage(img,(img.width-min)/2,(img.height-min)/2,min,min,0,0,size,size);
const data = canvas.toDataURL('image/jpeg',0.85);
setAvatar(data); localStorage.setItem('dd_avatar',data); toast('Avatar gespeichert');
}; img.src = ev.target.result;
}; reader.readAsDataURL(file);
}} style={{...S.inp,fontSize:13,padding:'7px 10px',cursor:'pointer'}}/>
</div>
</div>
{avatar && <button onClick={()=>{setAvatar(null);localStorage.removeItem('dd_avatar');toast('Avatar entfernt');}}
style={{...S.btn('#ff6b9d',true)}}> Entfernen</button>}
</Sec>
{user?.role==='admin' && <Sec title="BENUTZERNAME ÄNDERN">
<Field label="NEUER BENUTZERNAME" value={un.newUsername} onChange={e=>setUn(p=>({...p,newUsername:e.target.value}))} />
<Field label="PASSWORT ZUR BESTÄTIGUNG" type="password" value={un.unPw} onChange={e=>setUn(p=>({...p,unPw:e.target.value}))} />
<button onClick={changeUn} disabled={busy.un} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'11px 0' }}>
{busy.un ? '…' : '✓ Benutzername ändern'}
</button>
</Sec>}
<Sec title="PASSWORT ÄNDERN">
<Field label="AKTUELLES PASSWORT" type="password" value={pw.current} onChange={e=>setPw(p=>({...p,current:e.target.value}))} />
<Field label="NEUES PASSWORT" type="password" value={pw.newPw} onChange={e=>setPw(p=>({...p,newPw:e.target.value}))} />
<Field label="BESTÄTIGEN" type="password" value={pw.confirm} onChange={e=>setPw(p=>({...p,confirm:e.target.value}))} />
<button onClick={changePw} disabled={busy.pw} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'11px 0' }}>
{busy.pw ? '…' : '✓ Passwort ändern'}
</button>
</Sec>
<Sec title="ACCOUNT LÖSCHEN">
<p style={{ color:'rgba(255,255,255,0.55)', fontSize:12, fontFamily:'monospace', marginBottom:12, lineHeight:1.6 }}>
Löscht deinen Account und alle deine Daten unwiderruflich.
</p>
<DeleteAccountSection toast={toast} user={user}/>
</Sec>
<Sec title="PUSHOVER BENACHRICHTIGUNGEN">
<PushoverSettings toast={toast}/>
</Sec>
</div>
)}
{section === 'sicherheit' && (
<SecuritySettings toast={toast}/>
)}
{section === 'benutzer' && user?.role==='admin' && (
<div>
<SecuritySettingsAdmin toast={toast}/>
<UserManagement toast={toast}/>
<Sec title="DATEI-MANAGER LIMITS">
<DateiSettings toast={toast}/>
</Sec>
</div>
)}
{section === 'dashboard' && (
<div>
<Sec title="SCHNELLZUGRIFF">
<QuickLinksSettings toast={toast}/>
</Sec>
<Sec title="KALENDER (iCal)">
<CalendarSettings toast={toast}/>
</Sec>
</div>
)}
{section === 'backup' && (
<div>
<Sec title="DATENBANK">
<p style={{ color:'rgba(255,255,255,0.55)', fontSize:12, fontFamily:'monospace', marginBottom:12, lineHeight:1.6 }}>
Vollständige SQLite-Datenbank sichern oder wiederherstellen.
</p>
<button onClick={backup} style={{ ...S.btn('#ffe66d'), width:'100%', textAlign:'center', padding:'11px 0', marginBottom:12 }}>
Backup herunterladen
</button>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SICHERUNG EINSPIELEN (.db)</label>
<div style={{ display:'flex', gap:8 }}>
<input type="file" accept=".db" onChange={e => setFile(e.target.files[0])}
style={{ ...S.inp, flex:1, padding:'9px 10px', cursor:'pointer', fontSize:13 }} />
<button onClick={restore} disabled={busy.restore || !file}
style={{ ...S.btn('#ff6b9d'), opacity: busy.restore || !file ? 0.4 : 1, padding:'0 14px', flexShrink:0 }}>
{busy.restore ? '…' : '↑'}
</button>
</div>
{file && <div style={{ color:'rgba(255,255,255,0.55)', fontSize:10, fontFamily:'monospace', marginTop:5 }}>{file.name}</div>}
</Sec>
</div>
)}
</div>
);
}
// ── App Shell ─────────────────────────────────────────────────────────────────
export default function App() {
const mobile = useIsMobile();
const [splash, setSplash] = useState(() => {
// Only show splash in standalone PWA mode, and only once per session
return window.matchMedia('(display-mode: standalone)').matches && !sessionStorage.getItem('splash_shown');
});
const [user,setUser]=useState(null);
const [active,setActiveRaw]=useState('dashboard');
const setActive = (page) => {
import('./main.jsx').then(m => m.reloadIfNewBuild?.()).catch(()=>{});
setActiveRaw(page);
};
const [toastMsg,setToastMsg]=useState({msg:'',type:'success'});
const [updateInfo,setUpdateInfo]=useState(null);
const [showUpd,setShowUpd]=useState(false);
const [unreadMsgs, setUnreadMsgs] = useState(0);
const [unreadBoard, setUnreadBoard] = useState(0);
const [unreadPromoted, setUnreadPromoted] = useState(0);
const [unreadChangelog,setUnreadChangelog]= useState(0);
const [authChecked, setAuthChecked] = useState(false);
useEffect(()=>{
const token=localStorage.getItem('sk_token');
if(token){
try{const p=JSON.parse(atob(token.split('.')[1]));
if(p.exp*1000>Date.now())setUser({id:p.id,username:p.username,role:p.role});
else localStorage.removeItem('sk_token');
}catch{localStorage.removeItem('sk_token');}
}
setAuthChecked(true);
},[]);
useEffect(()=>{
if(!user)return;
const check=()=>api('/system/update-check').then(setUpdateInfo).catch(()=>{});
check();
const t=setInterval(check,5*60*1000);
return()=>clearInterval(t);
},[user]);
// Schlüssel sofort nach Login initialisieren und Public Key registrieren
useEffect(()=>{
if(!user)return;
getOrCreateKeyPair().then(async kp=>{
try{
const jwk=await getPublicKeyJwk(kp);
await api('/tools/nachrichten/keys',{body:{public_key:JSON.stringify(jwk)}});
}catch{}
}).catch(()=>{});
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/dashboard/board/unread').then(d=>{ setUnreadBoard(d.unread||0); setUnreadPromoted(d.unreadPromoted||0); }).catch(()=>{});
poll();
const t=setInterval(poll,30*1000);
return()=>clearInterval(t);
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/dashboard/changelog/unread').then(d=>setUnreadChangelog(d.unread||0)).catch(()=>{});
poll();
const t=setInterval(poll,60*1000);
return()=>clearInterval(t);
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(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);
},[]);
if(splash) return <SplashScreen onDone={()=>{ setSplash(false); sessionStorage.setItem('splash_shown','1'); }}/>;
if(!authChecked) return <div style={{background:'#0d0d0f',minHeight:'100vh'}}/>;
if(!user) return <Login onLogin={u=>setUser(u)}/>;
const activeTool = TOOLS.find(t=>t.id===active);
// Mobile padding bottom für fixed nav
const isNachrichten = active === 'nachrichten';
const mainStyle = {
flex:1,
overflowY: 'auto',
...(mobile ? { paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' } : {}),
};
return(
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
html,body{background:#0d0d0f;-webkit-tap-highlight-color:transparent;}
input,textarea,select{-webkit-appearance:none;appearance:none;}
input[type=number]::-webkit-inner-spin-button{opacity:0.25;}
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
::-webkit-scrollbar{width:3px;}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:2px;}
select option,textarea{background:#111114;}
`}</style>
<div style={{display:'flex',minHeight:'100vh',background:'#111114'}}>
{!mobile && (
<Sidebar active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}
unreadMsgs={unreadMsgs}/>
)}
<main style={mainStyle}>
{active==='dashboard' && <Dashboard toast={toast} mobile={mobile} setActive={setActive} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} user={user}/>}
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive}/>}
</main>
</div>
{mobile && (
<BottomNav active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}
unreadMsgs={unreadMsgs}/>
)}
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
{showUpd&&updateInfo&&<UpdateModal data={updateInfo} onClose={()=>setShowUpd(false)}/>}
</>
);
}