3856 lines
205 KiB
JavaScript
3856 lines
205 KiB
JavaScript
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, 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 ──────────────────────────────────────────────────────────────
|
||
// ── Desktop Sidebar ───────────────────────────────────────────────────────────
|
||
function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) {
|
||
const [appsOpen, setAppsOpen] = useState(true);
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||
const toggleGroup = (group) => setCollapsedGroups(prev => ({ ...prev, [group]: !prev[group] }));
|
||
|
||
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' }}/>
|
||
)}
|
||
{collapsed && item.id==='gebietseroberung' && hexWarsTurns>0 && (
|
||
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
|
||
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
|
||
)}
|
||
{collapsed && item.id==='whiteboard' && whiteboardUnread>0 && (
|
||
<span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
|
||
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>
|
||
)}
|
||
</span>
|
||
{!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>
|
||
)}
|
||
{!collapsed && item.id==='gebietseroberung' && hexWarsTurns>0 && (
|
||
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9,
|
||
fontFamily:'monospace', padding:'1px 6px', fontWeight:700, flexShrink:0 }}>{hexWarsTurns}</span>
|
||
)}
|
||
{!collapsed && item.id==='whiteboard' && whiteboardUnread>0 && (
|
||
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10, fontSize:9,
|
||
fontFamily:'monospace', padding:'1px 6px', fontWeight:700, flexShrink:0 }}>{whiteboardUnread}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
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', overflowY:'auto', overflowX:'hidden' }}>
|
||
<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(isAdmin).map(([group, tools]) => {
|
||
const isGroupCollapsed = !!collapsedGroups[group];
|
||
return (
|
||
<div key={group}>
|
||
{!collapsed && (
|
||
<button onClick={() => toggleGroup(group)} style={{
|
||
width:'100%', display:'flex', alignItems:'center', gap:4,
|
||
background:'none', border:'none', cursor:'pointer',
|
||
padding:'8px 12px 3px 12px',
|
||
}}>
|
||
<span style={{ color:'rgba(78,205,196,0.85)', fontSize:9, fontFamily:'monospace', letterSpacing:2, flex:1, textAlign:'left' }}>
|
||
{group.toUpperCase()}
|
||
</span>
|
||
<ChevronIcon size={10} color="rgba(78,205,196,0.5)" dir={isGroupCollapsed ? 'right' : 'down'} />
|
||
</button>
|
||
)}
|
||
{!isGroupCollapsed && tools.map(t => <NavBtn key={t.id} item={t} sub />)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<NavBtn item={{ id:'admin', Icon:AdminIcon, label:'Einstellungen' }} />
|
||
</nav>
|
||
<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, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) {
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [appsOpen, setAppsOpen] = useState(false);
|
||
const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({});
|
||
const NAV_H = 56;
|
||
|
||
const MI = ({Icon, icon, label, onClick, color='rgba(255,255,255,0.85)', dot=false}) => {
|
||
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', position:'relative' }}>
|
||
<span style={{flexShrink:0, width:22, display:'flex', alignItems:'center', justifyContent:'center', position:'relative'}}>
|
||
{Ic ? <Ic size={18} color={color}/> : <span style={{fontSize:17, color}}>{icon}</span>}
|
||
{dot && <span style={{ position:'absolute', top:-3, right:-3, width:7, height:7,
|
||
borderRadius:'50%', background:'#4ecdc4', boxShadow:'0 0 5px #4ecdc4' }}/>}
|
||
</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={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}>
|
||
{getGroupedTools(user?.role==='admin').map(([group, tools]) => {
|
||
const isGrpCollapsed = !!mobileCollapsedGroups[group];
|
||
return (
|
||
<div key={group}>
|
||
<button onClick={() => setMobileCollapsedGroups(prev => ({ ...prev, [group]: !prev[group] }))}
|
||
style={{ width:'100%', display:'flex', alignItems:'center', gap:6,
|
||
background:'none', border:'none', cursor:'pointer', padding:'12px 20px 5px' }}>
|
||
<span style={{ color:'rgba(78,205,196,0.85)', fontSize:9, fontFamily:'monospace',
|
||
letterSpacing:2, flex:1, textAlign:'left' }}>{group.toUpperCase()}</span>
|
||
<ChevronIcon size={10} color="rgba(78,205,196,0.5)" dir={isGrpCollapsed ? 'right' : 'down'} />
|
||
</button>
|
||
{!isGrpCollapsed && tools.map(t => (
|
||
<MI key={t.id} Icon={t.Icon}
|
||
label={t.label} dot={(t.id==='gebietseroberung' && hexWarsTurns>0) || (t.id==='whiteboard' && whiteboardUnread>0)}
|
||
onClick={() => { setActive(t.id); setAppsOpen(false); }} />
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Sheet>
|
||
|
||
{/* Mehr Sheet */}
|
||
<Sheet open={menuOpen} onClose={()=>setMenuOpen(false)}>
|
||
<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' }}>
|
||
{isApps && (hexWarsTurns>0 || unreadMsgs>0 || whiteboardUnread>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, onFolderClick, navigateTool }) {
|
||
const perRow = 4;
|
||
const gap = 6;
|
||
const pageSize = perRow * 2;
|
||
const pageCount = Math.ceil(links.length / pageSize);
|
||
|
||
const [page, setPage] = useState(0);
|
||
const scrollRef = useRef(null);
|
||
|
||
const onScroll = () => {
|
||
if (!scrollRef.current) return;
|
||
const p = Math.round(scrollRef.current.scrollLeft / scrollRef.current.offsetWidth);
|
||
setPage(p);
|
||
};
|
||
|
||
const canLeft = page > 0;
|
||
const canRight = page < pageCount - 1;
|
||
|
||
const scrollTo = (p) => {
|
||
scrollRef.current?.scrollTo({ left: p * scrollRef.current.offsetWidth, behavior:'smooth' });
|
||
};
|
||
|
||
if (pageCount <= 1) return (
|
||
<div style={{ display:'grid', gridTemplateColumns:`repeat(${perRow}, 1fr)`, gap }}>
|
||
{links.map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={onFolderClick} navigateTool={navigateTool}/>)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div style={{ position:'relative', overflow:'hidden' }}>
|
||
<div ref={scrollRef} onScroll={onScroll} style={{
|
||
display:'flex', overflowX:'auto', scrollSnapType:'x mandatory',
|
||
scrollbarWidth:'none', WebkitOverflowScrolling:'touch',
|
||
}}>
|
||
{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:'100%', minWidth:'100%', boxSizing:'border-box',
|
||
}}>
|
||
{links.slice(pi*pageSize, (pi+1)*pageSize).map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={onFolderClick} navigateTool={navigateTool}/>)}
|
||
</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, onFolderClick, navigateTool }) {
|
||
const [imgOk, setImgOk] = useState(true);
|
||
const isTool = l.url?.startsWith('tool://');
|
||
|
||
const hasCustomIcon = l.icon && l.icon !== '🔗';
|
||
const faviconUrl = (!hasCustomIcon && !l.isFolder && !isTool) ? (() => {
|
||
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(l.url).hostname}`; }
|
||
catch { return null; }
|
||
})() : null;
|
||
|
||
const showFavicon = !hasCustomIcon && !!faviconUrl && imgOk && !l.isFolder && !isTool;
|
||
const showCustomImg = hasCustomIcon && !l.isFolder && l.icon.startsWith('http');
|
||
const showEmoji = !showFavicon && !showCustomImg;
|
||
const emoji = l.isFolder ? (l.icon||'📁') : (hasCustomIcon ? l.icon : (isTool ? '⚡' : '🔗'));
|
||
|
||
const content = (
|
||
<>
|
||
{showFavicon && <img src={faviconUrl} alt="" style={{width:18,height:18,objectFit:'contain'}} onError={()=>setImgOk(false)}/>}
|
||
{showCustomImg && <img src={l.icon} alt="" style={{width:18,height:18,objectFit:'contain'}} onError={()=>setImgOk(false)}/>}
|
||
{showEmoji && <span style={{fontSize:18}}>{emoji}</span>}
|
||
<span style={{color:'rgba(255,255,255,0.65)',fontSize:8,fontFamily:'monospace',
|
||
maxWidth:52,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.title||l.name}</span>
|
||
</>
|
||
);
|
||
|
||
const baseStyle = {display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:3,
|
||
padding:'4px 2px',background: isTool ? 'rgba(160,132,250,0.06)' : 'rgba(255,255,255,0.04)',
|
||
border: l.isFolder ? '1px solid rgba(78,205,196,0.25)' : isTool ? '1px solid rgba(160,132,250,0.2)' : '1px solid rgba(255,255,255,0.08)',
|
||
borderRadius:10,textDecoration:'none',height:48,width:'100%',boxSizing:'border-box',cursor:'pointer'};
|
||
|
||
if (isTool) {
|
||
return <button onClick={()=>navigateTool?.(l.url)} style={baseStyle}>{content}</button>;
|
||
}
|
||
if (l.isFolder && onFolderClick) {
|
||
const realId = typeof l.id==='string'&&l.id.startsWith('folder-') ? parseInt(l.id.replace('folder-','')) : l.id;
|
||
return <button onClick={()=>onFolderClick({...l,id:realId})} style={{...baseStyle,background:'rgba(78,205,196,0.04)'}}>{content}</button>;
|
||
}
|
||
return <a href={l.url} target="_blank" rel="noopener noreferrer" style={baseStyle}>{content}</a>;
|
||
}
|
||
|
||
function FolderModal({ folder, onClose, navigateTool }) {
|
||
const [links, setLinks] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
useEffect(()=>{
|
||
api(`/tools/linkliste/folders/${folder.id}/links`)
|
||
.then(d=>{ setLinks(d.links||[]); setLoading(false); })
|
||
.catch(()=>setLoading(false));
|
||
},[folder.id]);
|
||
const mob = window.innerWidth < 768;
|
||
return (
|
||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:6000,
|
||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
|
||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:480,
|
||
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
|
||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:15,fontWeight:700}}>
|
||
{folder.icon} {folder.name}
|
||
</div>
|
||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',
|
||
cursor:'pointer',fontSize:20,padding:'0 4px'}}>✕</button>
|
||
</div>
|
||
{loading ? <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,textAlign:'center',padding:20}}>…</div>
|
||
: links.length===0 ? <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,textAlign:'center',padding:20}}>Ordner ist leer</div>
|
||
: <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(72px,1fr))',gap:8}}>
|
||
{links.map(l=><QuickLinkIcon key={l.id} l={l} navigateTool={navigateTool}/>)}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuickLinks({ toast, mobile, setActive, setDevToolsNav }) {
|
||
const [links, setLinks] = useState([]);
|
||
const [folderModal, setFolderModal] = useState(null);
|
||
const [editMode, setEditMode] = useState(false);
|
||
|
||
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
|
||
|
||
// Tool-Navigation für tool://-Links
|
||
const navigateTool = (url) => {
|
||
if (!url?.startsWith('tool://')) return;
|
||
const withoutScheme = url.replace('tool://', '');
|
||
const [toolId, query] = withoutScheme.split('?');
|
||
const sub = query?.replace('sub=', '');
|
||
if (toolId === 'devtools' && sub && setDevToolsNav) {
|
||
setActive('devtools');
|
||
setDevToolsNav({ tool: sub, ts: Date.now() });
|
||
} else if (setActive) {
|
||
setActive(toolId);
|
||
}
|
||
};
|
||
|
||
const legacyItems = links.filter(l => l.item_type === 'quicklink' || !l.item_type);
|
||
const hasLegacy = legacyItems.length > 0;
|
||
|
||
const deleteLegacy = async id => {
|
||
try {
|
||
await api(`/dashboard/links/${id}`, { method:'DELETE' });
|
||
setLinks(p => p.filter(l => l.id !== id));
|
||
toast('Entfernt');
|
||
} catch(e) { toast(e.message,'error'); }
|
||
};
|
||
|
||
const allItems = links.map(l => l.item_type === 'folder'
|
||
? { ...l, id:`folder-${l.id}`, isFolder:true, icon:l.icon||'📁', title:l.name }
|
||
: l
|
||
);
|
||
|
||
return (
|
||
<div style={{ ...S.card, marginBottom:10 }}>
|
||
{folderModal && <FolderModal folder={folderModal} onClose={()=>setFolderModal(null)} navigateTool={navigateTool}/>}
|
||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:10 }}>
|
||
<div style={S.head}>SCHNELLZUGRIFF</div>
|
||
{hasLegacy && (
|
||
<button onClick={()=>setEditMode(p=>!p)} style={{
|
||
background:'transparent', border:'none', color:'rgba(255,255,255,0.3)',
|
||
cursor:'pointer', fontFamily:'monospace', fontSize:10, padding:'2px 6px'}}>
|
||
{editMode ? '✓ Fertig' : '✎'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit-Modus: alte Quick-Links verwalten */}
|
||
{editMode && hasLegacy && (
|
||
<div style={{marginBottom:12,padding:'10px 12px',background:'rgba(255,255,255,0.03)',
|
||
borderRadius:8,border:'1px solid rgba(255,255,255,0.07)'}}>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:8}}>
|
||
ÄLTERE LINKS (aus Einstellungen) — können hier gelöscht werden
|
||
</div>
|
||
{legacyItems.map(l=>(
|
||
<div key={l.id} style={{display:'flex',alignItems:'center',gap:8,padding:'5px 0',
|
||
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||
<span style={{fontSize:14,flexShrink:0}}>{l.icon?.startsWith('http')
|
||
? <img src={l.icon} style={{width:14,height:14,objectFit:'contain',verticalAlign:'middle'}} alt=""/>
|
||
: l.icon||'🔗'}</span>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,
|
||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.title}</div>
|
||
</div>
|
||
<button onClick={()=>deleteLegacy(l.id)}
|
||
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'3px 8px',flexShrink:0}}>✕</button>
|
||
</div>
|
||
))}
|
||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginTop:8,lineHeight:1.6}}>
|
||
Neue Links über Linkliste → ● Schnellzugriff verwalten
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{allItems.length === 0
|
||
? <div style={{ color:'rgba(255,255,255,0.5)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>
|
||
Links oder Ordner in der Linkliste mit ● Schnellzugriff aktivieren.
|
||
</div>
|
||
: mobile
|
||
? <QuickLinksCarousel links={allItems} onFolderClick={setFolderModal} navigateTool={navigateTool}/>
|
||
: <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(58px,1fr))', gap:6 }}>
|
||
{allItems.map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={l.isFolder?setFolderModal:undefined} navigateTool={navigateTool}/>)}
|
||
</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(() => {});
|
||
};
|
||
// Sofort beim Mount laden damit Badges im zugeklappten Zustand sichtbar sind
|
||
useEffect(() => { load(); }, []);
|
||
const toggle = () => { setOpen(v => !v); };
|
||
|
||
const Row = ({ label, val, color='rgba(255,255,255,0.85)', sub }) => (
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||
padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||
<div>
|
||
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:12}}>{label}</span>
|
||
{sub && <span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10,marginLeft:6}}>{sub}</span>}
|
||
</div>
|
||
<span style={{color,fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>{val}</span>
|
||
</div>
|
||
);
|
||
|
||
// Status-Badge für zugeklappte Ansicht
|
||
const Badge = ({ val, color, label }) => val > 0 ? (
|
||
<span style={{display:'inline-flex',alignItems:'center',gap:3,
|
||
background:`${color}18`,border:`1px solid ${color}44`,
|
||
borderRadius:10,padding:'1px 7px',fontSize:10,color,fontFamily:'monospace',whiteSpace:'nowrap'}}>
|
||
{val} {label}
|
||
</span>
|
||
) : null;
|
||
|
||
return (
|
||
<div style={{...S.card,marginBottom:10}}>
|
||
<button onClick={toggle} style={{width:'100%',background:'transparent',border:'none',
|
||
display:'flex',alignItems:'center',cursor:'pointer',padding:0,gap:8,flexWrap:'wrap'}}>
|
||
<div style={{...S.head,marginBottom:0,flexShrink:0}}>3D-DRUCK STATISTIK</div>
|
||
{/* Status-Badges im zugeklappten Zustand */}
|
||
{!open && stats && (
|
||
<div style={{display:'flex',gap:3,flexWrap:'nowrap',flex:1,overflow:'hidden'}}>
|
||
<Badge val={stats.byStatus.warteliste||0} color="#ffe66d" label="W"/>
|
||
<Badge val={stats.byStatus.in_arbeit||0} color="#4ecdc4" label="A"/>
|
||
<Badge val={stats.byStatus.fertig||0} color="#6bcb77" label="F"/>
|
||
<Badge val={stats.bezahltOrders} color="#c084fc" label="B"/>
|
||
</div>
|
||
)}
|
||
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11,marginLeft:'auto',flexShrink:0,
|
||
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>
|
||
) : (
|
||
<>
|
||
{/* Bestellungen */}
|
||
<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>
|
||
|
||
{/* Einnahmen + Gewinn */}
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{...S.head,marginBottom:6}}>EINNAHMEN</div>
|
||
<Row label="Eingenommen" val={`${stats.totalRevenue.toFixed(2)} €`} color="#6bcb77" sub="bezahlt"/>
|
||
<Row label="Offen" val={`${stats.offeneRevenue.toFixed(2)} €`} color="#ffe66d" sub="in Arbeit/Fertig"/>
|
||
</div>
|
||
|
||
{/* Gewinn */}
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{...S.head,marginBottom:6}}>GEWINN <span style={{color:'rgba(255,255,255,0.25)',fontWeight:400,fontSize:9}}>nach Materialkosten</span></div>
|
||
<Row
|
||
label="Verdient"
|
||
val={`${(stats.totalProfit??0).toFixed(2)} €`}
|
||
color={(stats.totalProfit??0) >= 0 ? '#6bcb77' : '#f87171'}
|
||
sub="bezahlt"
|
||
/>
|
||
<Row
|
||
label="Ausstehend"
|
||
val={`${(stats.offeneProfit??0).toFixed(2)} €`}
|
||
color={(stats.offeneProfit??0) >= 0 ? '#ffe66d' : '#f87171'}
|
||
sub="in Arbeit/Fertig"
|
||
/>
|
||
<Row
|
||
label="Gesamt-Gewinn"
|
||
val={`${((stats.totalProfit??0)+(stats.offeneProfit??0)).toFixed(2)} €`}
|
||
color="rgba(255,255,255,0.5)"
|
||
sub="bezahlt + offen"
|
||
/>
|
||
</div>
|
||
|
||
<div style={{borderTop:'1px solid rgba(255,255,255,0.05)',paddingTop:8}}>
|
||
<Row label="Archiv-Einträge" val={stats.totalCalcs} color="rgba(255,255,255,0.4)"/>
|
||
</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
|
||
? (isNaN(Number(e.build_time))
|
||
? e.build_time // altes Format: bereits lesbarer String
|
||
: new Date(Number(e.build_time)*1000).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}))
|
||
: 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 formatIcalDate(dt) {
|
||
if (!dt) return '';
|
||
try {
|
||
const hasTime = dt.includes('T');
|
||
const isUTC = dt.endsWith('Z');
|
||
const y = dt.slice(0,4), mo = dt.slice(4,6), d = dt.slice(6,8);
|
||
if (hasTime) {
|
||
const h = dt.slice(9,11), mi = dt.slice(11,13), s = dt.slice(13,15)||'00';
|
||
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${isUTC?'Z':''}`;
|
||
const date = new Date(iso);
|
||
if (isNaN(date)) return dt;
|
||
return date.toLocaleString('de-DE', {
|
||
timeZone:'Europe/Berlin',
|
||
day:'2-digit', month:'2-digit', year:'numeric',
|
||
hour:'2-digit', minute:'2-digit'
|
||
}) + ' Uhr';
|
||
} else {
|
||
const date = new Date(`${y}-${mo}-${d}T12:00:00Z`);
|
||
if (isNaN(date)) return dt;
|
||
return date.toLocaleDateString('de-DE', {
|
||
timeZone:'Europe/Berlin', day:'2-digit', month:'2-digit', year:'numeric'
|
||
});
|
||
}
|
||
} catch { return dt.slice(0,10); }
|
||
}
|
||
|
||
function SearchResultItem({ item, idx, activeIdx, onNavigate, onHover }) {
|
||
const isActive = idx === activeIdx;
|
||
let icon = '❓', primary = '', secondary = '';
|
||
if (item.type==='tool') { icon=item.icon||'🛠'; primary=item.label; secondary=item.sub||'Navigation'; }
|
||
if (item.type==='devtool') { icon=item.icon||'🔧'; primary=item.label; secondary=item.sub||'Dev-Tools'; }
|
||
if (item.type==='settings') { icon=item.icon||'⚙️'; primary=item.label; secondary=item.sub||'Einstellungen'; }
|
||
if (item.type==='folder') { icon=item.icon||'📁'; primary=item.name; secondary='Linkliste – Ordner'; }
|
||
if (item.type==='link') {
|
||
const hasCustom = item.icon && item.icon !== '🔗';
|
||
icon = hasCustom ? item.icon : '🔗';
|
||
primary = item.title; secondary = item.url;
|
||
}
|
||
if (item.type==='snippet') { icon='📄'; primary=item.title; secondary=`Code · ${item.language||''}`; }
|
||
if (item.type==='calc') { icon='🖨'; primary=item.name; secondary='3D-Archiv'; }
|
||
if (item.type==='order') { icon='📦'; primary=item.name; secondary=`Bestellung · ${item.status||''}`; }
|
||
if (item.type==='todo') { icon=item.done?'✅':'☐'; primary=item.text; secondary='ToDo'; }
|
||
if (item.type==='note') { icon='📝'; primary='Notiz'; secondary=item.preview||''; }
|
||
if (item.type==='board') { icon=item.btype==='roadmap'?'🗺':'⭐'; primary=item.title; secondary=`${item.btype==='roadmap'?'Roadmap':'Wünsche'}${item.description?' · '+item.description.slice(0,40):''}`; }
|
||
if (item.type==='changelog') { icon='📋'; primary=`${item.version} – ${item.title}`; secondary='Changelog'; }
|
||
if (item.type==='file') { icon='📄'; primary=item.originalname; secondary=`Datei · ${item.size?(item.size/1024/1024).toFixed(1)+' MB':''}`; }
|
||
if (item.type==='file_folder') { icon='📁'; primary=item.name; secondary='Datei-Ordner'; }
|
||
if (item.type==='push') { icon='🔔'; primary=item.message; secondary=`Push · ${(item.scheduled_at||'').slice(0,16)}`; }
|
||
if (item.type==='calendar_feed') { icon='📅'; primary=item.name; secondary='Kalender-Abo'; }
|
||
if (item.type==='calendar_event') { icon='📆'; primary=item.summary; secondary=`${item.feed_name||'Kalender'}${item.start_dt?' · '+formatIcalDate(item.start_dt):''}`; }
|
||
if (item.type==='qr') { icon='◻'; primary=item.label||item.url; secondary=`QR-Code · ${item.url}`; }
|
||
if (item.type==='geo') { icon='⬡'; primary=`vs ${item.opp_name||item.owner_name}`; secondary=item.status==='active'?'Hex Wars · laufend':'Hex Wars · beendet'; }
|
||
return (
|
||
<div onClick={()=>onNavigate(item)} onMouseEnter={()=>onHover(idx)}
|
||
style={{display:'flex',alignItems:'center',gap:10,padding:'9px 14px',cursor:'pointer',
|
||
background:isActive?'rgba(78,205,196,0.1)':'transparent',
|
||
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||
<span style={{fontSize:16,flexShrink:0,width:24,textAlign:'center'}}>{icon}</span>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{color:isActive?'#4ecdc4':'#fff',fontFamily:'monospace',fontSize:13,fontWeight:500,
|
||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{primary}</div>
|
||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,
|
||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{secondary}</div>
|
||
</div>
|
||
{item.type==='link' && <span style={{color:'rgba(255,255,255,0.2)',fontSize:10,flexShrink:0}}>↗</span>}
|
||
{(item.type==='tool'||item.type==='devtool'||item.type==='settings'||item.type==='folder'||item.type==='snippet'||item.type==='calc'||item.type==='order') && <span style={{color:'rgba(255,255,255,0.2)',fontSize:10,flexShrink:0}}>→</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Globale Suche ─────────────────────────────────────────────────────────────
|
||
// Statischer Such-Index – mit Keywords für umfassende Suche
|
||
const SEARCH_TOOL_INDEX = [
|
||
// ── Haupt-Tools ──────────────────────────────────────────────────────────
|
||
{type:'tool',label:'Dashboard',icon:'🏠',sub:'',id:'dashboard'},
|
||
{type:'tool',label:'Bestellungen',icon:'📦',sub:'3D-Druck',id:'bestellungen',keywords:['order','auftrag','3d druck']},
|
||
{type:'tool',label:'Statistik',icon:'📊',sub:'3D-Druck',id:'statistik3d',keywords:['statistik','ausgaben','gewinn','haushalt','diagramm']},
|
||
{type:'tool',label:'Kalkulator3D',icon:'🖨',sub:'3D-Druck',id:'kalkulator3d',keywords:['kalkulator','rechner','3d archiv','archiv','berechnen','filament','druckkosten']},
|
||
{type:'tool',label:'3D-Archiv',icon:'🖨',sub:'3D-Druck',id:'kalkulator3d',keywords:['archiv','druck']},
|
||
{type:'tool',label:'Dateien',icon:'📁',sub:'Werkzeuge',id:'dateien',keywords:['datei','file','upload','dokument','ordner','speicher']},
|
||
{type:'tool',label:'Nachrichten',icon:'💬',sub:'Werkzeuge',id:'nachrichten',keywords:['chat','nachricht','message','kommunikation','e2e','verschlüsselt']},
|
||
{type:'tool',label:'Linkliste',icon:'🔗',sub:'Werkzeuge',id:'linkliste',keywords:['link','url','lesezeichen','bookmark','webseite']},
|
||
{type:'tool',label:'Code-Schnipsel',icon:'📄',sub:'Werkzeuge',id:'codeschnipsel',keywords:['code','snippet','skript','programmierung','source']},
|
||
{type:'tool',label:'CAD-Skizzen',icon:'📐',sub:'Werkzeuge',id:'skizze',keywords:['cad','skizze','zeichnung','sketch']},
|
||
{type:'tool',label:'Kanban',icon:'🗂',sub:'Werkzeuge',id:'kanban',keywords:['kanban','board','aufgaben','task','todo','karte','spalte','planung']},
|
||
{type:'tool',label:'Whiteboard',icon:'🖊',sub:'Werkzeuge',id:'whiteboard',keywords:['whiteboard','zeichnen','canvas','board','draw','skizze','malen','zeichen']},
|
||
{type:'tool',label:'Dev-Tools',icon:'🔧',sub:'Werkzeuge',id:'devtools',keywords:['entwickler','developer','tools','werkzeuge']},
|
||
{type:'tool',label:'Paywall-Killer',icon:'🔓',sub:'Werkzeuge',id:'paywallkiller',keywords:['paywall','archiv','archive','artikel','zeitung','bild','waz','heise','spiegel','zeit','bypass','umgehen','lesen','gesperrt','bezahlschranke']},
|
||
{type:'tool',label:'Hex Wars',icon:'⬡',sub:'Freizeit',id:'gebietseroberung',keywords:['hex wars','spiel','game','multiplayer','gebiet','strategie','minen','gold','nebel','einladen','duell','gebietseroberung']},
|
||
{type:'tool',label:'KöPi',icon:'🍺',sub:'Freizeit',id:'koepi',keywords:['köpi','koepi','könig pilsener','bier','angebot','prospekt','rewe','edeka','netto','trinkgut','kaufda']},
|
||
{type:'tool',label:'Media',icon:'🎬',sub:'Freizeit',id:'media',keywords:['kino','film','movie','streaming','demnächst','favoriten','cinema']},
|
||
{type:'tool',label:'Kino – Aktuell',icon:'🎬',sub:'Media',id:'media',keywords:['kino','kinocharts','charts','laufend','now playing']},
|
||
{type:'tool',label:'Kino – Demnächst',icon:'🗓',sub:'Media',id:'media',keywords:['demnächst','neustart','upcoming','vorschau','kino']},
|
||
{type:'tool',label:'Kino – Favoriten',icon:'❤',sub:'Media',id:'media',keywords:['favoriten','film favoriten','gemerkte filme','watchlist']},
|
||
// ── Dashboard-Widgets ──────────────────────────────────────────────────
|
||
{type:'tool',label:'Schnellzugriff',icon:'⚡',sub:'Dashboard',id:'dashboard',keywords:['quick','links','favoriten','shortcuts']},
|
||
{type:'tool',label:'Kalender',icon:'📅',sub:'Dashboard',id:'dashboard',keywords:['kalender','termine','events','ical','abo']},
|
||
{type:'tool',label:'ToDo',icon:'✅',sub:'Dashboard',id:'dashboard',keywords:['todo','aufgabe','task','erledigt','checkliste']},
|
||
{type:'tool',label:'Notizen',icon:'📝',sub:'Dashboard',id:'dashboard',keywords:['notiz','note','memo','text']},
|
||
{type:'tool',label:'Push-Erinnerungen',icon:'🔔',sub:'Dashboard',id:'dashboard',keywords:['push','erinnerung','benachrichtigung','reminder','alarm']},
|
||
{type:'tool',label:'Board / Roadmap',icon:'🗺',sub:'Dashboard',id:'dashboard',keywords:['roadmap','wünsche','board','ideen','planung','feature']},
|
||
{type:'tool',label:'Changelog',icon:'📋',sub:'Dashboard',id:'dashboard',keywords:['changelog','versionen','updates','änderungen']},
|
||
// ── Einstellungen ────────────────────────────────────────────────────────
|
||
{type:'settings',label:'Profil',icon:'👤',sub:'Einstellungen',section:'profil',keywords:['avatar','benutzername','username','account','konto']},
|
||
{type:'settings',label:'Passwort ändern',icon:'🔑',sub:'Einstellungen → Profil',section:'profil',keywords:['passwort','password','sicherheit','ändern']},
|
||
{type:'settings',label:'Benutzername ändern',icon:'✏️',sub:'Einstellungen → Profil',section:'profil',keywords:['benutzername','username','name']},
|
||
{type:'settings',label:'Pushover',icon:'🔔',sub:'Einstellungen → Profil',section:'profil',keywords:['pushover','push','api key','token','benachrichtigung','notification']},
|
||
{type:'settings',label:'Account löschen',icon:'⚠️',sub:'Einstellungen → Profil',section:'profil',keywords:['löschen','delete','entfernen']},
|
||
{type:'settings',label:'Sicherheit',icon:'🔒',sub:'Einstellungen',section:'sicherheit',keywords:['login','sperrung','schutz','lockout','versuche']},
|
||
{type:'settings',label:'Login-Schutz',icon:'🔒',sub:'Einstellungen → Sicherheit',section:'sicherheit',keywords:['login schutz','lockout','sperrung','fehlversuche']},
|
||
{type:'settings',label:'Dashboard-Einstellungen',icon:'🏠',sub:'Einstellungen',section:'dashboard',keywords:['schnellzugriff','kalender','ical','widgets']},
|
||
{type:'settings',label:'iCal Kalender hinzufügen',icon:'📅',sub:'Einstellungen → Dashboard',section:'dashboard',keywords:['ical','kalender','abo','feed','url']},
|
||
{type:'settings',label:'Benutzer verwalten',icon:'👥',sub:'Einstellungen (Admin)',section:'benutzer',keywords:['user','benutzer','admin','anlegen','erstellen','löschen']},
|
||
{type:'settings',label:'Datei-Limits',icon:'📁',sub:'Einstellungen (Admin)',section:'benutzer',keywords:['datei limit','speicher','quota','upload limit']},
|
||
{type:'settings',label:'Backup',icon:'💾',sub:'Einstellungen (Admin)',section:'backup',keywords:['backup','sicherung','wiederherstellen','export','import','datenbank']},
|
||
// ── Crontab ─────────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Crontab',icon:'⏱',sub:'Dev-Tools',tool:'cron',keywords:['cron','cronjob','schedule','zeitplan','scheduler','interval','task','job','automatisch','wiederkehrend']},
|
||
{type:'devtool',label:'Crontab erklären',icon:'⏱',sub:'Crontab',tool:'cron',keywords:['erklären','explain','verstehen','bedeutung','syntax','auslesen']},
|
||
{type:'devtool',label:'Crontab erstellen',icon:'⏱',sub:'Crontab',tool:'cron',keywords:['erstellen','generator','bauen','konfigurieren','jede minute','täglich','wöchentlich']},
|
||
// ── Elektro ─────────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Elektro-Rechner',icon:'⚡',sub:'Dev-Tools',tool:'elektro',keywords:['elektro','elektrizität','elektronik','schaltkreis','physik']},
|
||
{type:'devtool',label:'Ohmsches Gesetz U=R·I',icon:'⚡',sub:'Elektro',tool:'elektro',keywords:['ohm','ohmsches gesetz','spannung','strom','widerstand','volt','ampere','resistance','voltage','current','u=ri','u=r*i']},
|
||
{type:'devtool',label:'Leistung P=U·I',icon:'⚡',sub:'Elektro',tool:'elektro',keywords:['leistung','watt','kilowatt','power','p=ui','p=u*i','energieverbrauch','verbrauch']},
|
||
{type:'devtool',label:'Stromkosten berechnen',icon:'⚡',sub:'Elektro',tool:'elektro',keywords:['stromkosten','kosten','preis','kwh','kilowattstunde','jahreskosten','monatlich','preisrechner']},
|
||
{type:'devtool',label:'Widerstände Serie & Parallel',icon:'⚡',sub:'Elektro',tool:'elektro',keywords:['widerstand','serie','parallel','reihenschaltung','parallelschaltung','gesamtwiderstand']},
|
||
{type:'devtool',label:'Wirkungsgrad',icon:'⚡',sub:'Elektro',tool:'elektro',keywords:['wirkungsgrad','effizienz','efficiency','verluste','nutzleistung','eta']},
|
||
// ── JSON ──────────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'JSON Viewer / Builder',icon:'{}',sub:'Dev-Tools',tool:'json',keywords:['json','javascript object','daten','struktur','objekt','array','xml','konvertieren','formatieren','prettify','minify','validieren']},
|
||
// ── Regex ─────────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Regex Builder',icon:'🔍',sub:'Dev-Tools',tool:'regex',keywords:['regex','regexp','regulärer ausdruck','pattern','muster','match','suchen','validierung','e-mail regex','plz regex','telefon regex','url regex','ip regex','datum regex','zahlen regex']},
|
||
// ── Text Diff ────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Text Diff',icon:'±',sub:'Dev-Tools',tool:'diff',keywords:['diff','vergleich','unterschied','unterschiede','änderungen','delta','merge','texte vergleichen','versions vergleich']},
|
||
// ── Netzwerk ────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Netzwerk-Rechner',icon:'🌐',sub:'Dev-Tools',tool:'netzwerk',keywords:['netzwerk','network','internet','lan','wan','router']},
|
||
{type:'devtool',label:'Geschwindigkeit: Mbit/s ↔ MB/s',icon:'🌐',sub:'Netzwerk',tool:'netzwerk',keywords:['geschwindigkeit','speed','mbit','gbit','megabit','gigabit','mb/s','gb/s','mbit/s','gbit/s','megabyte','gigabyte','bandbreite','bandwidth','download','upload','transfer','übertragung','internetgeschwindigkeit','downloadzeit','dauer']},
|
||
{type:'devtool',label:'IP / Subnetz Rechner',icon:'🌐',sub:'Netzwerk',tool:'netzwerk',keywords:['ip','ipv4','ipv6','subnet','subnetz','subnetzmaske','netzmaske','gateway','cidr','broadcast','netzadresse','hostbereich','slash notation']},
|
||
{type:'devtool',label:'CIDR Tabelle',icon:'🌐',sub:'Netzwerk',tool:'netzwerk',keywords:['cidr','prefix','klasse a','klasse b','klasse c','hosts','adressen','/24','/16','/8']},
|
||
// ── Datetime ─────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Datetime / Zeitrechner',icon:'📅',sub:'Dev-Tools',tool:'datetime',keywords:['datum','zeit','time','date','rechner','kalender']},
|
||
{type:'devtool',label:'Unix Timestamp ↔ Datum',icon:'📅',sub:'Datetime',tool:'datetime',keywords:['unix','timestamp','epoch','sekunden','millisekunden','umrechnen','konvertieren','unixzeit']},
|
||
{type:'devtool',label:'Moseszeit Umrechner',icon:'📅',sub:'Datetime',tool:'datetime',keywords:['moses','moseszeit','moses time','proprietär','spezial']},
|
||
{type:'devtool',label:'Geburtstagsrechner',icon:'🎂',sub:'Datetime',tool:'datetime',keywords:['geburtstag','alter','geburtsdatum','jahre','monate','tage','birthday','age','wie alt']},
|
||
{type:'devtool',label:'Zeitdifferenz berechnen',icon:'📅',sub:'Datetime',tool:'datetime',keywords:['zeitdifferenz','differenz','abstand','dauer','zeitraum','zwischen','tage berechnen','stunden berechnen']},
|
||
{type:'devtool',label:'Datum / Kalenderwoche',icon:'📅',sub:'Datetime',tool:'datetime',keywords:['kalenderwoche','kw','wochentag','weekday','woche']},
|
||
// ── Farben ──────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Farb-Rechner',icon:'🎨',sub:'Dev-Tools',tool:'farben',keywords:['farbe','color','colour','design','palette']},
|
||
{type:'devtool',label:'HEX ↔ RGB ↔ HSL ↔ CMYK',icon:'🎨',sub:'Farben',tool:'farben',keywords:['hex','rgb','hsl','hsv','cmyk','oklch','rgba','hsla','farbraum','konvertieren','farbwert','farbcode','#ffffff','css farbe']},
|
||
{type:'devtool',label:'Color Picker',icon:'🎨',sub:'Farben',tool:'farben',keywords:['color picker','farbwähler','farbauswahl','pipette','picker']},
|
||
{type:'devtool',label:'WCAG Kontrast',icon:'🎨',sub:'Farben',tool:'farben',keywords:['wcag','kontrast','accessibility','barrierefrei','contrast ratio','lesbarkeit','aa','aaa']},
|
||
// ── Key-Gen ─────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'Key-Generator',icon:'🔐',sub:'Dev-Tools',tool:'keygen',keywords:['key','schlüssel','geheim','secret','generator','passwort','zufällig','sicher','random']},
|
||
{type:'devtool',label:'Laravel APP_KEY',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['laravel','app_key','php','framework','base64:']},
|
||
{type:'devtool',label:'JWT Secret HS256/HS512',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['jwt','json web token','hs256','hs512','bearer','auth token','authentication']},
|
||
{type:'devtool',label:'AES-256 / AES-128 Key',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['aes','aes256','aes128','verschlüsselung','encryption','symmetrisch','cipher']},
|
||
{type:'devtool',label:'UUID v4',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['uuid','uid','guid','identifier','eindeutig','unique id']},
|
||
{type:'devtool',label:'NanoID',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['nanoid','nano','kurz','compact','id']},
|
||
{type:'devtool',label:'Passwort Generator',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['passwort','password','sicher','stark','zufällig','sonderzeichen']},
|
||
{type:'devtool',label:'Docker / DB Passwort',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['docker','mysql','postgres','mariadb','db passwort','datenbank passwort','compose']},
|
||
{type:'devtool',label:'Hex / Base64 Token',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['hex','base64','base64url','token','random bytes','crypto']},
|
||
{type:'devtool',label:'NEXTAUTH_SECRET',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['nextauth','next auth','nextjs','next.js','auth.js']},
|
||
{type:'devtool',label:'VAPID Key',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['vapid','web push','push notification','service worker']},
|
||
{type:'devtool',label:'OAuth / Webhook Secret',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['oauth','webhook','signing secret','hmac','client secret']},
|
||
{type:'devtool',label:'Session Secret',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['session','express session','cookie secret','session_secret']},
|
||
{type:'devtool',label:'MYSQL / POSTGRES Passwort',icon:'🔐',sub:'Key-Gen',tool:'keygen',keywords:['mysql','mysql_root_password','postgres','postgresql','postgres_password','mariadb']},
|
||
// ── QR-Code ────────────────────────────────────────────────────────
|
||
{type:'devtool',label:'QR-Code Generator',icon:'◻',sub:'Dev-Tools',tool:'qrcode',keywords:['qr','qr code','qrcode','barcode','scan','scannen','link teilen','url teilen']},
|
||
];
|
||
|
||
function GlobalSearch({ setActive, setDevToolsNav, mobile }) {
|
||
const [query, setQuery] = useState('');
|
||
const [results, setResults] = useState(null);
|
||
const [active, setActiveR] = useState(-1); // keyboard nav
|
||
const [loading, setLoading] = useState(false);
|
||
const inputRef = useRef(null);
|
||
const listRef = useRef(null);
|
||
const debounce = useRef(null);
|
||
|
||
const close = () => { setQuery(''); setResults(null); setActiveR(-1); };
|
||
|
||
// Static matches
|
||
function matchStatic(q) {
|
||
const lq = q.toLowerCase();
|
||
return SEARCH_TOOL_INDEX.filter(t =>
|
||
t.label.toLowerCase().includes(lq) ||
|
||
t.sub.toLowerCase().includes(lq) ||
|
||
(t.tool||t.id||'').toLowerCase().includes(lq) ||
|
||
(t.keywords||[]).some(k => k.toLowerCase().includes(lq))
|
||
).slice(0, 8);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!query.trim()) { setResults(null); setActiveR(-1); return; }
|
||
clearTimeout(debounce.current);
|
||
debounce.current = setTimeout(async () => {
|
||
const staticMatches = matchStatic(query);
|
||
if (query.trim().length < 2) { setResults({static:staticMatches,links:[],snippets:[],calculations:[],orders:[]}); return; }
|
||
setLoading(true);
|
||
try {
|
||
const d = await api(`/search?q=${encodeURIComponent(query.trim())}`);
|
||
setResults({ static:staticMatches, ...d });
|
||
} catch { setResults({static:staticMatches,links:[],snippets:[],calculations:[],orders:[]}); }
|
||
setLoading(false);
|
||
}, 250);
|
||
}, [query]);
|
||
|
||
// Alle Einträge flach für Keyboard-Nav
|
||
const allItems = results ? [
|
||
...results.static,
|
||
...(results.links||[]).map(l=>({...l,type:'link'})),
|
||
...(results.folders||[]).map(f=>({...f,type:'folder'})),
|
||
...(results.snippets||[]).map(s=>({...s,type:'snippet'})),
|
||
...(results.calculations||[]).map(c=>({...c,type:'calc'})),
|
||
...(results.orders||[]).map(o=>({...o,type:'order'})),
|
||
...(results.todos||[]).map(t=>({...t,type:'todo'})),
|
||
...(results.notes||[]).map(n=>({...n,type:'note'})),
|
||
...(results.board_items||[]).map(b=>({...b,type:'board'})),
|
||
...(results.changelog||[]).map(e=>({...e,type:'changelog'})),
|
||
...(results.files||[]).map(f=>({...f,type:'file'})),
|
||
...(results.file_folders||[]).map(f=>({...f,type:'file_folder'})),
|
||
...(results.push_schedules||[]).map(p=>({...p,type:'push'})),
|
||
...(results.calendar_feeds||[]).map(f=>({...f,type:'calendar_feed'})),
|
||
...(results.calendar_events||[]).map(e=>({...e,type:'calendar_event'})),
|
||
...(results.qr_codes||[]).map(q=>({...q,type:'qr'})),
|
||
] : [];
|
||
|
||
const navigate = (item) => {
|
||
close();
|
||
if (item.type === 'tool') { setActive(item.id); }
|
||
else if (item.type === 'devtool') { setActive('devtools'); setDevToolsNav({tool: item.tool, ts: Date.now()}); }
|
||
else if (item.type === 'settings') { setActive('admin'); setDevToolsNav({section: item.section, ts: Date.now()}); }
|
||
else if (item.type === 'link') { window.open(item.url, '_blank', 'noopener'); }
|
||
else if (item.type === 'folder') { setActive('linkliste'); }
|
||
else if (item.type === 'snippet') { setActive('codeschnipsel'); }
|
||
else if (item.type === 'calc') { setActive('kalkulator3d'); }
|
||
else if (item.type === 'order') { setActive('bestellungen'); }
|
||
else if (item.type === 'todo') { setActive('dashboard'); }
|
||
else if (item.type === 'note') { setActive('dashboard'); }
|
||
else if (item.type === 'board') { setActive('dashboard'); }
|
||
else if (item.type === 'changelog') { setActive('dashboard'); }
|
||
else if (item.type === 'file' || item.type === 'file_folder') { setActive('dateien'); }
|
||
else if (item.type === 'push') { setActive('dashboard'); }
|
||
else if (item.type === 'calendar_feed' || item.type === 'calendar_event') { setActive('dashboard'); }
|
||
else if (item.type === 'qr') { setActive('devtools'); setDevToolsNav({tool:'qrcode', ts: Date.now()}); }
|
||
};
|
||
|
||
const onKey = e => {
|
||
if (!results) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveR(p => Math.min(p+1, allItems.length-1)); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveR(p => Math.max(p-1, -1)); }
|
||
else if (e.key === 'Enter' && active >= 0 && allItems[active]) { navigate(allItems[active]); }
|
||
else if (e.key === 'Escape') { close(); inputRef.current?.blur(); }
|
||
};
|
||
|
||
|
||
|
||
const hasResults = allItems.length > 0;
|
||
const showDropdown = !!query.trim() && results !== null;
|
||
|
||
return (
|
||
<div style={{position:'relative',marginBottom:16}}>
|
||
<div style={{position:'relative'}}>
|
||
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',
|
||
color:'rgba(255,255,255,0.4)',fontSize:16,pointerEvents:'none'}}>⌕</span>
|
||
<input
|
||
ref={inputRef}
|
||
value={query}
|
||
onChange={e=>setQuery(e.target.value)}
|
||
onKeyDown={onKey}
|
||
placeholder="Suchen in der App… Tools, Links, Code, Bestellungen"
|
||
style={{...S.inp, paddingLeft:38, paddingRight: query?36:14, width:'100%', boxSizing:'border-box', fontSize:14}}
|
||
/>
|
||
{!!query && (
|
||
<button onClick={close} style={{position:'absolute',right:10,top:'50%',transform:'translateY(-50%)',
|
||
background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 2px'}}>✕</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Dropdown */}
|
||
{showDropdown && (
|
||
<div ref={listRef} style={{
|
||
position:'absolute',top:'calc(100% + 6px)',left:0,right:0,zIndex:9000,
|
||
background:'#1a1a1e',borderRadius:12,border:'1px solid rgba(255,255,255,0.12)',
|
||
boxShadow:'0 8px 32px rgba(0,0,0,0.6)',maxHeight:'60vh',overflowY:'auto',
|
||
}}>
|
||
{loading && (
|
||
<div style={{padding:'12px 14px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Suche…</div>
|
||
)}
|
||
{!loading && !hasResults && (
|
||
<div style={{padding:'16px 14px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12,textAlign:'center'}}>
|
||
Keine Treffer für „{query}"
|
||
</div>
|
||
)}
|
||
{!loading && hasResults && (
|
||
<>
|
||
{results.static?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>NAVIGATION</div>
|
||
{results.static.map((item,i)=><SearchResultItem key={`s${i}`} item={item} idx={i} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>)}
|
||
</div>
|
||
)}
|
||
{results.links?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>LINKS</div>
|
||
{results.links.map((item,i)=><SearchResultItem key={`l${i}`} item={{...item,type:'link'}} idx={results.static.length+i} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>)}
|
||
</div>
|
||
)}
|
||
{results.folders?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>LINK-ORDNER</div>
|
||
{results.folders.map((item,i)=>{
|
||
const off=results.static.length+(results.links||[]).length+(results.folders||[]).length;
|
||
return <SearchResultItem key={`f${i}`} item={{...item,type:'folder'}} idx={off+i} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>;
|
||
})}
|
||
</div>
|
||
)}
|
||
{results.snippets?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>CODE-SCHNIPSEL</div>
|
||
{results.snippets.map((item,i)=>{
|
||
const off=results.static.length+(results.links||[]).length;
|
||
return <SearchResultItem key={`sn${i}`} item={{...item,type:'snippet'}} idx={off+i} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>;
|
||
})}
|
||
</div>
|
||
)}
|
||
{results.calculations?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>3D-ARCHIV</div>
|
||
{results.calculations.map((item,i)=>{
|
||
const off=results.static.length+(results.links||[]).length+(results.folders||[]).length+(results.snippets||[]).length;
|
||
return <SearchResultItem key={`c${i}`} item={{...item,type:'calc'}} idx={off+i} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>;
|
||
})}
|
||
</div>
|
||
)}
|
||
{results.orders?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>BESTELLUNGEN</div>
|
||
{results.orders.map((item,i)=>{
|
||
const off=results.static.length+(results.links||[]).length+(results.folders||[]).length+(results.snippets||[]).length+(results.calculations||[]).length;
|
||
return <SearchResultItem key={`o${i}`} item={{...item,type:'order'}} idx={off+i} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>;
|
||
})}
|
||
</div>
|
||
)}
|
||
{results.todos?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>TODOS</div>
|
||
{results.todos.map((item,i)=>{
|
||
const off=allItems.length-((results.todos||[]).length+(results.files||[]).length+(results.file_folders||[]).length+(results.push_schedules||[]).length+(results.calendar_feeds||[]).length+(results.qr_codes||[]).length)+i;
|
||
return <SearchResultItem key={`t${i}`} item={{...item,type:'todo'}} idx={off} activeIdx={active} onNavigate={navigate} onHover={setActiveR}/>;
|
||
})}
|
||
</div>
|
||
)}
|
||
{results.files?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>DATEIEN</div>
|
||
{results.files.map((item,i)=>(<SearchResultItem key={`fi${i}`} item={{...item,type:'file'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.file_folders?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>DATEI-ORDNER</div>
|
||
{results.file_folders.map((item,i)=>(<SearchResultItem key={`ff${i}`} item={{...item,type:'file_folder'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.push_schedules?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>PUSH-ERINNERUNGEN</div>
|
||
{results.push_schedules.map((item,i)=>(<SearchResultItem key={`p${i}`} item={{...item,type:'push'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.calendar_feeds?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>KALENDER-ABOS</div>
|
||
{results.calendar_feeds.map((item,i)=>(<SearchResultItem key={`cf${i}`} item={{...item,type:'calendar'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.qr_codes?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>QR-CODES</div>
|
||
{results.qr_codes.map((item,i)=>(<SearchResultItem key={`qr${i}`} item={{...item,type:'qr'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.notes?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>NOTIZEN</div>
|
||
{results.notes.map((item,i)=>(<SearchResultItem key={`n${i}`} item={{...item,type:'note'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.board_items?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>ROADMAP / WÜNSCHE</div>
|
||
{results.board_items.map((item,i)=>(<SearchResultItem key={`b${i}`} item={{...item,type:'board',btype:item.type}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.changelog?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>CHANGELOG</div>
|
||
{results.changelog.map((item,i)=>(<SearchResultItem key={`cl${i}`} item={{...item,type:'changelog'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
{results.calendar_events?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>KALENDER-EINTRÄGE</div>
|
||
{results.calendar_events.map((item,i)=>(<SearchResultItem key={`ce${i}`} item={{...item,type:'calendar_event'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
{results.geo_games?.length>0 && (
|
||
<>
|
||
<div style={secHead}>HEX WARS</div>
|
||
{results.geo_games.map((item,i)=>(<SearchResultItem key={`geo${i}`} item={{...item,type:'geo'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{results.calendar_feeds?.length>0 && (
|
||
<div>
|
||
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>KALENDER-ABOS</div>
|
||
{results.calendar_feeds.map((item,i)=>(<SearchResultItem key={`cf${i}`} item={{...item,type:'calendar_feed'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Backdrop zum Schließen – leicht opak damit Dashboard nicht durchscheint */}
|
||
{showDropdown && (
|
||
<div style={{position:'fixed',inset:0,zIndex:8999,background:'rgba(0,0,0,0.25)'}} onClick={close}/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function Dashboard({ toast, mobile, setActive, setDevToolsNav, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, unreadChangelog=0, onChangelogRead, unackedFavs, onClearUnackedFavs, 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); }, []);
|
||
// Kalender-Events im Hintergrund für die Suche cachen
|
||
useEffect(() => {
|
||
api('/search/sync-calendar', { method:'POST', body:{} })
|
||
.then(r => { if (r.errors?.length) console.warn('[CalendarSync]', r); })
|
||
.catch(e => console.error('[CalendarSync] Fehler:', e));
|
||
}, []);
|
||
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>
|
||
{/* Globale Suche */}
|
||
<GlobalSearch setActive={setActive} setDevToolsNav={setDevToolsNav} mobile={mobile}/>
|
||
<QuickLinks toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} />
|
||
<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: ${new Date(Number(__BUILD_TIME__)*1000).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}` : '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>
|
||
{/* Unquittierte Favoriten für alle User */}
|
||
{(unackedFavs?.count || 0) > 0 && (
|
||
<button onClick={()=>{
|
||
if (unackedFavs?.isAdmin) {
|
||
setActive('media');
|
||
setTimeout(()=>window.dispatchEvent(new CustomEvent('media-navigate',{detail:'favoriten'})),200);
|
||
} else {
|
||
onClearUnackedFavs?.();
|
||
setActive('media');
|
||
setTimeout(()=>window.dispatchEvent(new CustomEvent('media-navigate',{detail:'favoriten'})),200);
|
||
}
|
||
}} style={{
|
||
background:'rgba(245,158,11,0.12)', border:'1px solid rgba(245,158,11,0.35)',
|
||
borderRadius:7, color:'#f59e0b', cursor:'pointer',
|
||
padding:'4px 10px', fontSize:9, fontFamily:'monospace', letterSpacing:1,
|
||
}}>
|
||
{unackedFavs?.isAdmin
|
||
? `⭐ ${unackedFavs.count} neue Favorit${unackedFavs.count!==1?'en':''}`
|
||
: `✅ ${unackedFavs.count} Favorit${unackedFavs.count!==1?'en':' wurde'} bestätigt`
|
||
}
|
||
</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'}}>
|
||
{!!u.has_pushover && (
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
await api(`/admin/users/${u.id}/test-push`, { method:'POST', body:{ message:'Wat is mit meinen Fische?' } });
|
||
toast(`Push an ${u.username} gesendet ✓`);
|
||
} catch(e) { toast(e.message, 'error'); }
|
||
}}
|
||
title={`Test-Push an ${u.username}`}
|
||
style={{...S.btn('#ff6b9d',true), fontSize:11, padding:'4px 8px'}}>
|
||
🔔
|
||
</button>
|
||
)}
|
||
<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>
|
||
);
|
||
|
||
// ── Admin: Upload-Freigabe Berechtigungen ─────────────────────────────────────
|
||
function TmdbSettings({ toast }) {
|
||
const [token, setToken] = useState('');
|
||
const [configured, setConfigured] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api('/tools/media/settings').then(d => setConfigured(d.configured)).catch(()=>{});
|
||
}, []);
|
||
|
||
const save = async () => {
|
||
if (!token.trim()) { toast('Token eingeben', 'error'); return; }
|
||
setBusy(true);
|
||
try {
|
||
await api('/tools/media/settings', { method:'PUT', body:{ tmdb_token: token.trim() } });
|
||
toast('TMDb Token gespeichert ✓');
|
||
setConfigured(true); setToken('');
|
||
} catch(e) { toast(e.message, 'error'); } finally { setBusy(false); }
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ fontSize:12, color:'rgba(255,255,255,0.45)', fontFamily:'monospace', marginBottom:10, lineHeight:1.6 }}>
|
||
API Read Access Token von <a href="https://www.themoviedb.org/settings/api" target="_blank"
|
||
rel="noopener noreferrer" style={{ color:'#4ecdc4' }}>themoviedb.org</a> eintragen.
|
||
{configured && <span style={{ color:'#4ecdc4', marginLeft:6 }}>✓ Konfiguriert</span>}
|
||
</div>
|
||
<Field label="BEARER TOKEN (API READ ACCESS TOKEN)" value={token} onChange={e=>setToken(e.target.value)}
|
||
type="password" placeholder={configured ? '••• Token ersetzen •••' : 'eyJhbGciOi…'} />
|
||
<button onClick={save} disabled={busy} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'11px 0' }}>
|
||
{busy ? '…' : '✓ Token speichern'}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UploadShareAdminPanel({ toast }) {
|
||
const [users, setUsers] = useState([]);
|
||
const [logs, setLogs] = useState([]);
|
||
const [showLogs, setShowLogs] = useState(false);
|
||
|
||
const load = () => {
|
||
api('/admin/users').then(setUsers).catch(()=>{});
|
||
};
|
||
const loadLogs = () => {
|
||
api('/upload-shares/admin/logs').then(setLogs).catch(()=>{});
|
||
setShowLogs(true);
|
||
};
|
||
useEffect(()=>{ load(); },[]);
|
||
|
||
const toggle = async (uid, current) => {
|
||
try {
|
||
await api('/upload-shares/admin/permission', { body:{ user_id: uid, allow: !current } });
|
||
setUsers(p => p.map(u => u.id===uid ? {...u, allow_upload_share: current?0:1} : u));
|
||
toast(current ? 'Berechtigung entzogen' : 'Berechtigung erteilt');
|
||
} catch(e) { toast(e.message,'error'); }
|
||
};
|
||
|
||
const fmtDate = d => d ? new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '–';
|
||
const fmtSize = b => b>1024*1024 ? (b/1024/1024).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB';
|
||
|
||
return (
|
||
<div>
|
||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,marginBottom:12,lineHeight:1.7}}>
|
||
Admins haben immer Zugriff auf Upload-Freigaben.<br/>
|
||
Hier kannst du die Funktion für einzelne Mitglieder freischalten.
|
||
</div>
|
||
{users.filter(u=>u.role!=='admin').map(u => (
|
||
<div key={u.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
|
||
<span style={{flex:1,color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
|
||
<button onClick={()=>toggle(u.id, !!u.allow_upload_share)}
|
||
style={{
|
||
padding:'4px 14px',borderRadius:16,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||
background: u.allow_upload_share ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.05)',
|
||
border: `1px solid ${u.allow_upload_share ? '#4ecdc4' : 'rgba(255,255,255,0.15)'}`,
|
||
color: u.allow_upload_share ? '#4ecdc4' : 'rgba(255,255,255,0.4)',
|
||
}}>
|
||
{u.allow_upload_share ? '● Erlaubt' : '○ Gesperrt'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button onClick={loadLogs} style={{...S.btn('#ffe66d',true),marginTop:14,fontSize:11}}>
|
||
📋 Nutzungsprotokoll anzeigen
|
||
</button>
|
||
{showLogs && (
|
||
<div style={{marginTop:12,background:'#131316',border:'1px solid rgba(255,255,255,0.08)',borderRadius:10,padding:'12px 14px'}}>
|
||
<div style={{...S.head,marginBottom:8}}>ALLE UPLOADS ÜBER FREIGABE-LINKS</div>
|
||
{logs.length===0
|
||
? <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Noch keine Uploads.</div>
|
||
: logs.map(l=>(
|
||
<div key={l.id} style={{display:'flex',gap:10,padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
|
||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,flexShrink:0,minWidth:90}}>{l.username}</span>
|
||
<div style={{flex:1,minWidth:120}}>
|
||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.originalname}</div>
|
||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>{fmtDate(l.uploaded_at)} · {fmtSize(l.size)} · Max: {l.max_size_mb} MB</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
|
||
// ── Waisen aufräumen ──────────────────────────────────────────────────────
|
||
function OrphanCleanup({ toast }) {
|
||
const [result, setResult] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
async function run(dry) {
|
||
setLoading(true); setResult(null);
|
||
try {
|
||
const d = await api('/admin/cleanup-orphans', { body: { dry_run: dry } });
|
||
setResult({ ...d, dry });
|
||
} catch(e) { toast('Fehler: ' + e.message); }
|
||
finally { setLoading(false); }
|
||
}
|
||
|
||
function fmtSize(b) {
|
||
if (b < 1024) return b + ' B';
|
||
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
||
return (b/1024/1024).toFixed(1) + ' MB';
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
|
||
Findet und löscht Dateien die auf Disk aber nicht in der DB sind (z.B. nach Ordner-Lösch-Bugs),
|
||
und DB-Einträge ohne Datei auf Disk.
|
||
</div>
|
||
<div style={{display:'flex',gap:8,marginBottom:12,flexWrap:'wrap'}}>
|
||
<button onClick={()=>run(true)} disabled={loading}
|
||
style={{...S.btn('#60a5fa'),opacity:loading?0.5:1}}>
|
||
{loading?'⏳...':'🔍 Analysieren (kein Löschen)'}
|
||
</button>
|
||
<button onClick={()=>run(false)} disabled={loading}
|
||
style={{...S.btn('#f87171'),opacity:loading?0.5:1}}>
|
||
{loading?'⏳...':'🗑 Waisen löschen'}
|
||
</button>
|
||
</div>
|
||
{result && (
|
||
<div style={{padding:'12px 14px',background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:8,fontFamily:'monospace',fontSize:11}}>
|
||
<div style={{color:'rgba(255,255,255,0.6)',marginBottom:8}}>
|
||
{result.dry ? '🔍 Analyse (nichts gelöscht)' : '🗑 Bereinigung abgeschlossen'}
|
||
</div>
|
||
<div style={{color:result.disk_orphans_count>0?'#f87171':'#4ade80',marginBottom:4}}>
|
||
Disk-Waisen: {result.disk_orphans_count}
|
||
{!result.dry && result.disk_deleted > 0 && <span style={{color:'#4ade80'}}> ({result.disk_deleted} gelöscht)</span>}
|
||
</div>
|
||
<div style={{color:result.db_orphans_count>0?'#f87171':'#4ade80',marginBottom:8}}>
|
||
DB-Waisen: {result.db_orphans_count}
|
||
{!result.dry && result.db_deleted > 0 && <span style={{color:'#4ade80'}}> ({result.db_deleted} gelöscht)</span>}
|
||
</div>
|
||
{result.disk_orphans.length > 0 && (
|
||
<div style={{marginBottom:6}}>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontSize:9,marginBottom:4}}>DISK-WAISEN:</div>
|
||
{result.disk_orphans.map((f,i) => (
|
||
<div key={i} style={{color:'rgba(255,255,255,0.3)',fontSize:9}}>
|
||
· {f.filename} ({fmtSize(f.size)})
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{result.db_orphans.length > 0 && (
|
||
<div>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontSize:9,marginBottom:4}}>DB-WAISEN:</div>
|
||
{result.db_orphans.map((f,i) => (
|
||
<div key={i} style={{color:'rgba(255,255,255,0.3)',fontSize:9}}>
|
||
· {f.originalname} ({f.filename})
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
|
||
function WebDavSettings({ toast }) {
|
||
const [cfg, setCfg] = useState({ url:'', user:'', password:'', basePath:'/dickendock', enabled:false });
|
||
const [testing, setTesting] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [syncResult, setSyncResult] = useState(null);
|
||
const [pulling, setPulling] = useState(false);
|
||
const [pullResult, setPullResult] = useState(null);
|
||
const [showPw, setShowPw] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api('/admin/webdav').then(d => setCfg(d)).catch(() => {});
|
||
}, []);
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
try {
|
||
await api('/admin/webdav', { method:'PUT', body: cfg });
|
||
toast('WebDAV Einstellungen gespeichert ✓');
|
||
} catch(e) { toast('Fehler: ' + e.message); }
|
||
finally { setSaving(false); }
|
||
}
|
||
|
||
async function test() {
|
||
setTesting(true);
|
||
try {
|
||
await api('/admin/webdav/test', { body: cfg });
|
||
toast('✓ Verbindung erfolgreich!');
|
||
} catch(e) { toast('✗ ' + e.message); }
|
||
finally { setTesting(false); }
|
||
}
|
||
|
||
const field = (label, key, type='text', placeholder='') => (
|
||
<div style={{marginBottom:10}}>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>{label}</div>
|
||
<div style={{position:'relative'}}>
|
||
<input
|
||
type={type === 'password' ? (showPw ? 'text' : 'password') : type}
|
||
value={cfg[key] || ''}
|
||
onChange={e => setCfg(p => ({...p, [key]: e.target.value}))}
|
||
placeholder={placeholder}
|
||
style={{...S.inp, width:'100%', boxSizing:'border-box', paddingRight: type==='password' ? 36 : 12}}
|
||
/>
|
||
{type === 'password' && (
|
||
<button onClick={()=>setShowPw(v=>!v)}
|
||
style={{position:'absolute',right:8,top:'50%',transform:'translateY(-50%)',background:'none',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:13}}>
|
||
{showPw ? '🙈' : '👁'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
{/* Enable Toggle */}
|
||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:16,cursor:'pointer'}}
|
||
onClick={() => setCfg(p => ({...p, enabled: !p.enabled}))}>
|
||
<div style={{
|
||
width:36, height:20, borderRadius:10, position:'relative',
|
||
background: cfg.enabled ? '#4ade80' : 'rgba(255,255,255,0.1)',
|
||
border: `1px solid ${cfg.enabled ? '#4ade80' : 'rgba(255,255,255,0.2)'}`,
|
||
transition: 'all 0.2s',
|
||
}}>
|
||
<div style={{
|
||
position:'absolute', top:2, left: cfg.enabled ? 18 : 2,
|
||
width:14, height:14, borderRadius:'50%', background:'#fff',
|
||
transition: 'left 0.2s',
|
||
}}/>
|
||
</div>
|
||
<span style={{color:cfg.enabled?'#4ade80':'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12}}>
|
||
{cfg.enabled ? 'WebDAV aktiv – Dateien werden auf NAS gespiegelt' : 'WebDAV deaktiviert'}
|
||
</span>
|
||
</div>
|
||
|
||
{field('WEBDAV URL', 'url', 'text', 'http://192.168.1.100:5005 oder https://nas.local:5006')}
|
||
{field('BENUTZERNAME', 'user', 'text', 'admin')}
|
||
{field('PASSWORT', 'password', 'password', '')}
|
||
{field('BASISPFAD AUF DER SYNOLOGY', 'basePath', 'text', '/dickendock')}
|
||
|
||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginBottom:16,lineHeight:1.6}}>
|
||
Dateien werden unter <span style={{color:'rgba(255,255,255,0.5)'}}>Basispfad/Username/Dateiname</span> gespeichert.<br/>
|
||
Beispiel: /dickendock/flo/dokumente/rechnung.pdf<br/>
|
||
Synology WebDAV aktivieren: Systemsteuerung → Dateidienste → WebDAV
|
||
</div>
|
||
|
||
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:12}}>
|
||
<button onClick={test} disabled={testing || !cfg.url}
|
||
style={{...S.btn('#60a5fa'), opacity:(testing||!cfg.url)?0.5:1}}>
|
||
{testing ? '⏳ Teste...' : '🔌 Verbindung testen'}
|
||
</button>
|
||
<button onClick={save} disabled={saving}
|
||
style={{...S.btn('#4ade80'), opacity:saving?0.5:1}}>
|
||
{saving ? '⏳...' : '💾 Speichern'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Sync Button */}
|
||
{cfg.enabled && (
|
||
<div style={{borderTop:'1px solid rgba(255,255,255,0.07)',paddingTop:14}}>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>
|
||
BESTEHENDE DATEIEN SYNCHRONISIEREN
|
||
</div>
|
||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginBottom:10,lineHeight:1.5}}>
|
||
Kopiert alle lokalen Dateien aus dem Docker-Container auf die Synology.
|
||
Bereits vorhandene Dateien werden überschrieben.
|
||
</div>
|
||
<button onClick={async()=>{
|
||
setSyncing(true); setSyncResult(null);
|
||
try {
|
||
const d = await api('/admin/webdav/sync', { body: {} });
|
||
setSyncResult(d);
|
||
} catch(e) { setSyncResult({ error: e.message }); }
|
||
finally { setSyncing(false); }
|
||
}} disabled={syncing}
|
||
style={{...S.btn('#f59e0b'), opacity:syncing?0.5:1, marginBottom:10}}>
|
||
{syncing ? '⏳ Synchronisiere...' : '📤 Alles auf Synology kopieren'}
|
||
</button>
|
||
|
||
{syncResult && !syncResult.error && (
|
||
<div style={{padding:'10px 12px',background:'rgba(74,222,128,0.08)',border:'1px solid rgba(74,222,128,0.3)',borderRadius:8,fontFamily:'monospace',fontSize:11}}>
|
||
<div style={{color:'#4ade80',marginBottom:4}}>
|
||
✓ {syncResult.message || `Sync abgeschlossen: ${syncResult.ok}/${syncResult.total} Dateien`}
|
||
</div>
|
||
{syncResult.failed > 0 && (
|
||
<div style={{color:'#f87171'}}>
|
||
✗ {syncResult.failed} fehlgeschlagen
|
||
{syncResult.errors?.slice(0,5).map((e,i) => (
|
||
<div key={i} style={{color:'rgba(248,113,113,0.7)',fontSize:9,marginTop:2}}>· {e}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{syncResult?.error && (
|
||
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>✗ {syncResult.error}</div>
|
||
)}
|
||
|
||
{/* Pull: Synology → App */}
|
||
<div style={{marginTop:14,borderTop:'1px solid rgba(255,255,255,0.07)',paddingTop:14}}>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>SYNOLOGY → APP ABGLEICHEN</div>
|
||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginBottom:10,lineHeight:1.5}}>
|
||
Liest neue/gelöschte Ordner von der Synology und spiegelt sie in die App.
|
||
</div>
|
||
<button onClick={async()=>{
|
||
setPulling(true); setPullResult(null);
|
||
try {
|
||
const d = await api('/admin/webdav/pull', { body: {} });
|
||
setPullResult(d);
|
||
} catch(e) { setPullResult({ error: e.message }); }
|
||
finally { setPulling(false); }
|
||
}} disabled={pulling}
|
||
style={{...S.btn('#60a5fa'), opacity:pulling?0.5:1, marginBottom:8}}>
|
||
{pulling ? '⏳ Lese Synology...' : '📥 Synology → App abgleichen'}
|
||
</button>
|
||
{pullResult && !pullResult.error && (
|
||
<div style={{padding:'8px 12px',background:'rgba(96,165,250,0.08)',border:'1px solid rgba(96,165,250,0.3)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#60a5fa'}}>
|
||
✓ {pullResult.message}
|
||
</div>
|
||
)}
|
||
{pullResult?.error && (
|
||
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>✗ {pullResult.error}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AdminPanel({ toast, mobile, user, nav }) {
|
||
const [section, setSection] = useState('profil');
|
||
useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]);
|
||
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>
|
||
<Sec title="UPLOAD-FREIGABE">
|
||
<UploadShareAdminPanel toast={toast}/>
|
||
</Sec>
|
||
<Sec title="TMDB API (KINO-FEATURE)">
|
||
<TmdbSettings toast={toast}/>
|
||
</Sec>
|
||
</div>
|
||
)}
|
||
|
||
{section === 'dashboard' && (
|
||
<div>
|
||
<Sec title="SCHNELLZUGRIFF">
|
||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,lineHeight:1.7}}>
|
||
Schnellzugriff-Links werden direkt in der <strong style={{color:'#4ecdc4'}}>Linkliste</strong> verwaltet.<br/>
|
||
Dort den ● Schnellzugriff-Button bei einem Link oder Ordner aktivieren.
|
||
</div>
|
||
</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>
|
||
<Sec title="SYNOLOGY NAS / WEBDAV">
|
||
<WebDavSettings toast={toast}/>
|
||
</Sec>
|
||
<Sec title="DATEI-SPEICHER AUFRÄUMEN">
|
||
<OrphanCleanup toast={toast}/>
|
||
</Sec>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ── App Shell ─────────────────────────────────────────────────────────────────
|
||
|
||
// ── Öffentliche Upload-Seite ─────────────────────────────────────────────────
|
||
|
||
// ── Öffentliche Datei/Ordner-Download-Seite ────────────────────────────────
|
||
function PublicFileShare({ token }) {
|
||
const [phase, setPhase] = useState('loading'); // loading|pw|ready|error
|
||
const [info, setInfo] = useState(null);
|
||
const [files, setFiles] = useState([]);
|
||
const [pw, setPw] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [errMsg, setErrMsg] = useState('');
|
||
const [dlLoading,setDlLoading]= useState(null);
|
||
|
||
const S2 = {
|
||
wrap: { minHeight:'100vh', background:'#0f1117', display:'flex', alignItems:'center', justifyContent:'center', padding:20 },
|
||
box: { background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:16, padding:32, maxWidth:480, width:'100%' },
|
||
head: { color:'#fff', fontFamily:'monospace', fontSize:18, fontWeight:700, marginBottom:6 },
|
||
sub: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:12, marginBottom:24 },
|
||
inp: { width:'100%', boxSizing:'border-box', background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'10px 14px', color:'#fff', fontFamily:'monospace', fontSize:13, outline:'none' },
|
||
btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'10px 16px', color:c, fontFamily:'monospace', fontSize:13, cursor:'pointer', width:'100%', marginTop:10 }),
|
||
fileRow: { display:'flex', alignItems:'center', gap:12, padding:'10px 14px', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8, marginBottom:8 },
|
||
err: { color:'#f87171', fontFamily:'monospace', fontSize:12, marginTop:8 },
|
||
};
|
||
|
||
const BASE = '/api/public/file-share/' + token;
|
||
|
||
useEffect(() => {
|
||
fetch(BASE)
|
||
.then(async r => {
|
||
const d = await r.json();
|
||
if (!r.ok) { setPhase('error'); return; }
|
||
setInfo(d);
|
||
setPhase('pw');
|
||
})
|
||
.catch(() => setPhase('error'));
|
||
}, [token]);
|
||
|
||
// Sofort redirect ohne Inhalt anzeigen
|
||
if (phase === 'error') {
|
||
window.location.replace('https://www.google.de');
|
||
return null;
|
||
}
|
||
|
||
async function handlePw() {
|
||
if (!pw.trim()) return;
|
||
setErrMsg('');
|
||
|
||
if (info?.is_folder) {
|
||
const res = await fetch(BASE + '/folder', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ password: pw }),
|
||
});
|
||
const d = await res.json();
|
||
if (!res.ok) { setErrMsg(d.error || 'Falsches Passwort'); return; }
|
||
setPassword(pw);
|
||
setFiles(d.files || []);
|
||
setPhase('ready');
|
||
} else {
|
||
// Datei: nur PW prüfen via /verify (kein Download)
|
||
const res = await fetch(BASE + '/verify', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ password: pw }),
|
||
});
|
||
const d = await res.json().catch(() => ({}));
|
||
if (!res.ok) { setErrMsg(d.error || 'Falsches Passwort'); return; }
|
||
setPassword(pw);
|
||
setPhase('ready');
|
||
}
|
||
}
|
||
|
||
async function downloadFile(fileId, fileName) {
|
||
setDlLoading(fileId || 'single');
|
||
try {
|
||
const url = BASE + (fileId ? '/folder/' + fileId : '/download');
|
||
const res = await fetch(url, {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
if (!res.ok) { const d = await res.json(); setErrMsg(d.error); return; }
|
||
const blob = await res.blob();
|
||
const objUrl = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = objUrl; a.download = fileName;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
||
} finally { setDlLoading(null); }
|
||
}
|
||
|
||
function fmtSize(b) {
|
||
if (!b) return '';
|
||
if (b < 1024) return b + ' B';
|
||
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
||
return (b/1024/1024).toFixed(1) + ' MB';
|
||
}
|
||
|
||
// Ablauf formatieren
|
||
const fmtExp = exp => exp
|
||
? new Date(exp*1000).toLocaleDateString('de-DE') + ' ' + new Date(exp*1000).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}) + ' Uhr'
|
||
: null;
|
||
|
||
return (
|
||
<div style={S2.wrap}>
|
||
<div style={S2.box}>
|
||
|
||
{/* Immer sichtbar: Bezeichnung + Ablauf */}
|
||
<div style={S2.head}>🔗 Freigabe</div>
|
||
{info?.label && (
|
||
<div style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:14,marginBottom:4}}>
|
||
{info.label}
|
||
</div>
|
||
)}
|
||
{info?.expires_at && (
|
||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:20}}>
|
||
Gültig bis {fmtExp(info.expires_at)}
|
||
</div>
|
||
)}
|
||
|
||
{/* PW-Eingabe */}
|
||
{phase === 'pw' && (
|
||
<>
|
||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:6}}>
|
||
🔒 Passwort erforderlich
|
||
</div>
|
||
<input value={pw} onChange={e=>setPw(e.target.value)}
|
||
onKeyDown={e=>e.key==='Enter'&&handlePw()} autoFocus
|
||
placeholder="Passwort eingeben" style={S2.inp} />
|
||
{errMsg && <div style={S2.err}>⚠ {errMsg}</div>}
|
||
<button onClick={handlePw} style={S2.btn('#4ecdc4')}>🔓 Entsperren</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Datei bereit */}
|
||
{phase === 'ready' && !info?.is_folder && (
|
||
<>
|
||
<div style={{...S2.fileRow, marginBottom:16}}>
|
||
<span style={{fontSize:24}}>📄</span>
|
||
<div>
|
||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{info.file_name}</div>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>{fmtSize(info.file_size)}</div>
|
||
</div>
|
||
</div>
|
||
<button onClick={() => downloadFile(null, info.file_name)} disabled={dlLoading==='single'}
|
||
style={S2.btn('#4ade80')}>
|
||
{dlLoading==='single' ? '⏳ Lädt...' : '⬇ Datei herunterladen'}
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* Ordner bereit */}
|
||
{phase === 'ready' && info?.is_folder && (
|
||
<>
|
||
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,marginBottom:12}}>
|
||
📁 {info.folder_name} · {files.length} Datei{files.length!==1?'en':''}
|
||
</div>
|
||
{files.map(f => (
|
||
<div key={f.id} style={S2.fileRow}>
|
||
<span style={{fontSize:18}}>📄</span>
|
||
<div style={{flex:1}}>
|
||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12}}>{f.name}</div>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>{fmtSize(f.size)}</div>
|
||
</div>
|
||
<button onClick={()=>downloadFile(f.id, f.name)} disabled={!!dlLoading}
|
||
style={{...S2.btn('#4ade80'), width:'auto', marginTop:0, padding:'6px 14px', fontSize:11}}>
|
||
{dlLoading===f.id ? '⏳' : '⬇'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
{files.length===0 && <div style={S2.sub}>Ordner ist leer.</div>}
|
||
</>
|
||
)}
|
||
|
||
{phase === 'loading' && (
|
||
<div style={S2.sub}>Lade...</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function PublicUpload({ token }) {
|
||
const [phase, setPhase] = useState('loading');
|
||
const [info, setInfo] = useState(null);
|
||
const [errorMsg, setErrorMsg] = useState('');
|
||
const [pw, setPw] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [uploading,setUploading]= useState(false);
|
||
const [done, setDone] = useState([]);
|
||
const [msg, setMsg] = useState(null);
|
||
const [attLeft, setAttLeft] = useState(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const fileRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
fetch('/api/upload-shares/public/' + token)
|
||
.then(async r => {
|
||
const d = await r.json();
|
||
if (!r.ok) { setErrorMsg(d.error||'Link ungültig'); setPhase('error'); return; }
|
||
setInfo(d); setPhase('pw');
|
||
})
|
||
.catch(e => { setErrorMsg('Verbindung fehlgeschlagen: '+e.message); setPhase('error'); });
|
||
}, [token]);
|
||
|
||
const verify = async () => {
|
||
if (!pw.trim()) return;
|
||
try {
|
||
const r = await fetch('/api/upload-shares/public/'+token+'/verify', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ password: pw })
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) {
|
||
setMsg({text:d.error,type:'error'});
|
||
if (d.locked) { setPhase('error'); setErrorMsg(d.error); return; }
|
||
if (d.attempts_left!=null) setAttLeft(d.attempts_left);
|
||
return;
|
||
}
|
||
setPassword(pw); setInfo(d); setPhase('upload'); setMsg(null);
|
||
} catch(e) { setMsg({text:'Netzwerkfehler: '+e.message,type:'error'}); }
|
||
};
|
||
|
||
const doUpload = async (droppedFiles) => {
|
||
const files = droppedFiles || fileRef.current?.files;
|
||
if (!files?.length) { fileRef.current?.click(); return; }
|
||
setUploading(true); setMsg(null);
|
||
const newDone = [];
|
||
for (const file of files) {
|
||
if (file.size > info.max_size_mb * 1024 * 1024) {
|
||
setMsg({text:`"${file.name}" zu groß (max ${info.max_size_mb} MB)`,type:'warn'}); continue;
|
||
}
|
||
const fd = new FormData(); fd.append('file', file);
|
||
try {
|
||
const r = await fetch('/api/upload-shares/public/'+token+'/upload', {
|
||
method:'POST', headers:{'x-upload-password': password}, body:fd
|
||
});
|
||
const d = await r.json();
|
||
if (r.ok) newDone.push(file.name);
|
||
else setMsg({text:d.error,type:'error'});
|
||
} catch(e) { setMsg({text:e.message,type:'error'}); }
|
||
}
|
||
if (newDone.length) {
|
||
setDone(p=>[...p,...newDone]);
|
||
setMsg({text:newDone.length+' Datei'+(newDone.length!==1?'en':'')+' hochgeladen ✓',type:'ok'});
|
||
}
|
||
if (fileRef.current) fileRef.current.value='';
|
||
setUploading(false);
|
||
};
|
||
|
||
const onDragOver = e => { e.preventDefault(); setDragging(true); };
|
||
const onDragLeave = e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); };
|
||
const onDrop = e => {
|
||
e.preventDefault(); setDragging(false);
|
||
if (!fileRef.current) return;
|
||
// DataTransfer.files is read-only – assign via input to trigger React update
|
||
const dt = e.dataTransfer;
|
||
if (!dt.files.length) return;
|
||
// Assign dropped files to the file input
|
||
Object.defineProperty(fileRef.current, 'files', { value: dt.files, writable: true });
|
||
fileRef.current.files = dt.files;
|
||
// Trigger display update by reading back
|
||
setMsg({text:`${dt.files.length} Datei${dt.files.length!==1?'en':''} ausgewählt`,type:'ok'});
|
||
doUpload(dt.files);
|
||
};
|
||
|
||
const fmtDate = d => new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||
|
||
const C = {
|
||
page: {minHeight:'100vh',background:'#0d0d0f',display:'flex',alignItems:'center',justifyContent:'center',padding:20},
|
||
card: {background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:16,padding:28,width:'100%',maxWidth:440},
|
||
inp: {width:'100%',background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',borderRadius:8,padding:'10px 12px',color:'#fff',fontFamily:"'Courier New',monospace",fontSize:14,outline:'none',boxSizing:'border-box'},
|
||
infoBox: {background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',borderRadius:8,padding:'10px 12px',fontSize:12,color:'rgba(255,255,255,0.6)',marginBottom:14},
|
||
btn: {width:'100%',background:'#4ecdc4',border:'none',borderRadius:8,color:'#0d0d0f',cursor:'pointer',fontFamily:"'Courier New',monospace",fontSize:14,fontWeight:700,padding:12,marginTop:12},
|
||
msg: t=>({borderRadius:8,padding:'10px 12px',fontSize:13,marginTop:10,...(t==='ok'?{background:'rgba(78,205,196,0.12)',border:'1px solid rgba(78,205,196,0.3)',color:'#4ecdc4'}:t==='warn'?{background:'rgba(255,230,109,0.12)',border:'1px solid rgba(255,230,109,0.3)',color:'#ffe66d'}:{background:'rgba(255,107,157,0.12)',border:'1px solid rgba(255,107,157,0.3)',color:'#ff6b9d'})}),
|
||
dropZone: active=>({border:`2px dashed ${active?'#4ecdc4':'rgba(255,255,255,0.18)'}`,borderRadius:12,padding:'28px 16px',textAlign:'center',cursor:'pointer',transition:'border-color 0.15s, background 0.15s',background:active?'rgba(78,205,196,0.07)':'rgba(255,255,255,0.02)',marginBottom:12}),
|
||
};
|
||
|
||
return (
|
||
<div style={{...C.page,fontFamily:"'Courier New',monospace"}}>
|
||
<div style={C.card}>
|
||
<h1 style={{fontSize:18,color:'#4ecdc4',margin:'0 0 4px'}}>📤 Datei hochladen</h1>
|
||
|
||
|
||
{phase==='loading' && <div style={C.infoBox}>⏳ Link wird geprüft…</div>}
|
||
{phase==='error' && (() => { window.location.replace('https://www.google.de'); return <div style={C.msg('error')}>Weiterleitung…</div>; })()}
|
||
|
||
{phase==='pw' && info && <>
|
||
<div style={C.infoBox}>🔐 Gültig bis {fmtDate(info.expires_at)} · max {info.max_size_mb} MB pro Datei</div>
|
||
<div style={{color:'rgba(255,255,255,0.45)',fontSize:10,letterSpacing:2,marginBottom:4}}>PASSWORT</div>
|
||
<input type="password" value={pw} onChange={e=>setPw(e.target.value)}
|
||
onKeyDown={e=>e.key==='Enter'&&verify()} placeholder="Passwort eingeben"
|
||
autoCapitalize="none" autoComplete="off" style={C.inp}/>
|
||
{attLeft!=null && <div style={{color:'#ffe66d',fontSize:11,marginTop:6}}>⚠ Noch {attLeft} Versuch{attLeft===1?'':'e'} übrig</div>}
|
||
<button onClick={verify} style={C.btn}>Zugang bestätigen</button>
|
||
{msg && <div style={C.msg(msg.type)}>{msg.text}</div>}
|
||
</>}
|
||
|
||
{phase==='upload' && info && <>
|
||
<div style={C.infoBox}>✓ Zugang OK · max {info.max_size_mb} MB pro Datei · bis {fmtDate(info.expires_at)}</div>
|
||
<div
|
||
style={C.dropZone(dragging)}
|
||
onDragOver={onDragOver}
|
||
onDragLeave={onDragLeave}
|
||
onDrop={onDrop}
|
||
onClick={()=>fileRef.current?.click()}
|
||
>
|
||
<div style={{fontSize:28,marginBottom:6}}>{dragging?'📂':'📁'}</div>
|
||
<div style={{color:dragging?'#4ecdc4':'rgba(255,255,255,0.5)',fontSize:13}}>
|
||
{dragging?'Loslassen zum Hochladen':'Dateien hierher ziehen oder klicken'}
|
||
</div>
|
||
<div style={{color:'rgba(255,255,255,0.25)',fontSize:11,marginTop:4}}>max {info.max_size_mb} MB pro Datei</div>
|
||
</div>
|
||
<input type="file" ref={fileRef} multiple onChange={()=>doUpload()} style={{display:'none'}}/>
|
||
<button onClick={()=>doUpload()} disabled={uploading} style={{...C.btn,opacity:uploading?0.5:1}}>
|
||
{uploading?'⏳ Lädt hoch…':'⬆ Hochladen'}
|
||
</button>
|
||
{msg && <div style={C.msg(msg.type)}>{msg.text}</div>}
|
||
{done.map((f,i)=><div key={i} style={{color:'rgba(78,205,196,0.7)',fontSize:11,padding:'2px 0'}}>✓ {f}</div>)}
|
||
</>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Scroll-to-Top Button ──────────────────────────────────────────────────
|
||
function ScrollToTop({ scrollRef }) {
|
||
const [visible, setVisible] = useState(false);
|
||
|
||
useEffect(() => {
|
||
// Prüfe sowohl window als auch den Ref-Container
|
||
const el = scrollRef?.current;
|
||
|
||
function check() {
|
||
const winScroll = window.scrollY || document.documentElement.scrollTop;
|
||
const elScroll = el ? el.scrollTop : 0;
|
||
setVisible(winScroll > 300 || elScroll > 300);
|
||
}
|
||
|
||
window.addEventListener('scroll', check, { passive: true });
|
||
if (el) el.addEventListener('scroll', check, { passive: true });
|
||
return () => {
|
||
window.removeEventListener('scroll', check);
|
||
if (el) el.removeEventListener('scroll', check);
|
||
};
|
||
}, [scrollRef]);
|
||
|
||
function scrollUp() {
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
if (scrollRef?.current) scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
if (!visible) return null;
|
||
|
||
return (
|
||
<button
|
||
onClick={scrollUp}
|
||
style={{
|
||
position: 'fixed',
|
||
bottom: 'calc(70px + env(safe-area-inset-bottom, 0px))',
|
||
right: 20,
|
||
zIndex: 900,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: '50%',
|
||
background: 'rgba(30,30,40,0.85)',
|
||
border: '1px solid rgba(255,255,255,0.18)',
|
||
backdropFilter: 'blur(10px)',
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontSize: 18,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||
}}
|
||
title="Nach oben"
|
||
>
|
||
↑
|
||
</button>
|
||
);
|
||
}
|
||
|
||
|
||
export default function App() {
|
||
// Öffentliche Upload-Seite – kein Login, kein Auth
|
||
const _uploadMatch = window.location.pathname.match(/^\/u\/(.+)$/);
|
||
if (_uploadMatch) return <PublicUpload token={_uploadMatch[1]}/>;
|
||
const _shareMatch = window.location.pathname.match(/^\/s\/(.+)$/);
|
||
if (_shareMatch) return <PublicFileShare token={_shareMatch[1]}/>;
|
||
|
||
const mobile = useIsMobile();
|
||
const mainRef = useRef(null);
|
||
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 [devToolsNav, setDevToolsNav] = useState(null);
|
||
const pollUnackedFavs = () => api('/tools/media/favorites/unacked').then(d=>setUnackedFavs({count:d.count||0,isAdmin:d.isAdmin})).catch(()=>{});
|
||
|
||
const setActive = (page) => {
|
||
if (page === 'dashboard' && window.__newBuildAvailable && window.__safeReloadIfNewBuild?.()) return;
|
||
if (page === 'dashboard') {
|
||
setDashboardKey(k => k + 1);
|
||
pollUnackedFavs(); // Sofort aktualisieren
|
||
}
|
||
setActiveRaw(page);
|
||
};
|
||
const [toastMsg,setToastMsg]=useState({msg:'',type:'success'});
|
||
const [unreadMsgs, setUnreadMsgs] = useState(0);
|
||
const [hexWarsTurns, setHexWarsTurns] = useState(0);
|
||
const [whiteboardUnread, setWhiteboardUnread] = useState(0);
|
||
const [unreadBoard, setUnreadBoard] = useState(0);
|
||
const [unreadPromoted, setUnreadPromoted] = useState(0);
|
||
const [unreadChangelog,setUnreadChangelog]= useState(0);
|
||
const [unackedFavs, setUnackedFavs] = useState({count:0,isAdmin:false});
|
||
const [mediaInitTab, setMediaInitTab] = useState(null);
|
||
const [dashboardKey, setDashboardKey] = 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;
|
||
},[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;
|
||
pollUnackedFavs(); const ti=setInterval(pollUnackedFavs,30000); return()=>clearInterval(ti);
|
||
},[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]);
|
||
|
||
useEffect(()=>{
|
||
if(!user)return;
|
||
const poll=()=>api('/tools/gebietseroberung/my-turns').then(d=>setHexWarsTurns(d.count||0)).catch(()=>{});
|
||
poll();
|
||
const t=setInterval(poll,30*1000);
|
||
return()=>clearInterval(t);
|
||
},[user]);
|
||
|
||
useEffect(()=>{
|
||
if(!user)return;
|
||
const poll=()=>api('/tools/whiteboard/unread').then(d=>setWhiteboardUnread(d.count||0)).catch(()=>{});
|
||
poll();
|
||
const t=setInterval(poll,30*1000);
|
||
return()=>clearInterval(t);
|
||
},[user]);
|
||
|
||
const toast=useCallback((msg,type='success')=>{
|
||
setToastMsg({msg,type});
|
||
setTimeout(()=>setToastMsg({msg:'',type:'success'}),3500);
|
||
},[]);
|
||
|
||
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',
|
||
minHeight: 0, // Wichtig für Flex: verhindert dass Container über Elterngröße wächst
|
||
...(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;height:100%;}
|
||
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',flexDirection:'row',height:'100dvh',background:'#111114'}}>
|
||
{!mobile && (
|
||
<Sidebar active={active} setActive={setActive} user={user}
|
||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
|
||
|
||
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/>
|
||
)}
|
||
<main ref={mainRef} style={mainStyle}>
|
||
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>}
|
||
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user} nav={devToolsNav}/>}
|
||
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} {...(activeTool.id==='devtools'?{nav:devToolsNav}:{})} {...(activeTool.id==='media' ? {initTab:mediaInitTab, onInitTabConsumed:()=>setMediaInitTab(null)} : {})}/>}
|
||
</main>
|
||
</div>
|
||
{mobile && (
|
||
<BottomNav active={active} setActive={setActive} user={user}
|
||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
|
||
|
||
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/>
|
||
)}
|
||
<ScrollToTop scrollRef={mainRef}/>
|
||
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
|
||
</>
|
||
);
|
||
}
|