Files
dickendock/frontend/src/App.jsx

1240 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from './lib.js';
import { TOOLS, getGroupedTools } from './toolRegistry.js';
import CalendarWidget, { CalendarSettings } from './calendar.jsx';
import { DateiSettings } from './tools/dateien.jsx';
import { useConfirm } from './confirm.jsx';
import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon } from './icons.jsx';
const ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'];
// ── 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); onLogin(d.user); }
catch(e) { setErr(e.message); } finally { setBusy(false); }
};
return (
<div style={{
position:'fixed', inset:0,
background:'#0d0d0f',
display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
padding:'24px 20px', overflow:'hidden',
}}>
<div style={{ width:'100%', maxWidth:340 }}>
<div style={{ textAlign:'center', marginBottom:36 }}>
<div style={{ width:56, height:56, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:14, display:'flex', alignItems:'center', justifyContent:'center',
fontSize:26, margin:'0 auto 14px' }}></div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:20, fontWeight:700 }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
</div>
{[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>(
<div key={l} style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:5 }}>{l}</label>
<input type={t} value={v} onChange={e=>s(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&go()}
style={{ ...S.inp, fontSize:16, padding:'13px 14px', borderRadius:10,
border:'1px solid rgba(255,255,255,0.12)' }}
autoCapitalize="none" autoCorrect="off" />
</div>
))}
{err && <div style={{ color:'#ff6b9d', fontSize:12, fontFamily:'monospace', marginBottom:12, textAlign:'center' }}> {err}</div>}
<button onClick={go} disabled={busy} style={{
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
borderRadius:12, padding:'15px 0', color:'#0d0d0f',
fontFamily:"'Space Mono',monospace", fontWeight:700, fontSize:15,
cursor:busy?'default':'pointer', opacity:busy?0.7:1, marginTop:6,
}}>{busy?'…':'Einloggen →'}</button>
</div>
</div>
);
}
// ── Update Modal ──────────────────────────────────────────────────────────────
function UpdateModal({ data, onClose }) {
return (
<Modal title="📦 Update verfügbar" onClose={onClose}>
<div style={{ marginBottom:14, padding:'8px 12px', background:'rgba(255,230,109,0.06)',
border:'1px solid rgba(255,230,109,0.2)', borderRadius:8 }}>
<span style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace' }}>
Installiert: <span style={{ color:'#4ecdc4' }}>{data.currentVersion}</span>
{' → '}<span style={{ color:'#ffe66d' }}>{data.latestVersion}</span>
</span>
</div>
{(data.releases||[]).map((r,i)=>(
<div key={i} style={{ marginBottom:12, padding:'12px 14px',
background:i===0?'rgba(78,205,196,0.05)':'rgba(255,255,255,0.02)',
border:`1px solid ${i===0?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)'}`, borderRadius:8 }}>
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
<span style={{ color:i===0?'#4ecdc4':'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>{r.name||r.version}</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontSize:10, fontFamily:'monospace' }}>
{r.publishedAt?new Date(r.publishedAt).toLocaleDateString('de-DE'):''}
</span>
</div>
<pre style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace', whiteSpace:'pre-wrap', lineHeight:1.6, margin:0 }}>{r.body}</pre>
</div>
))}
<div style={{ color:'rgba(255,255,255,0.55)', fontSize:11, fontFamily:'monospace', marginTop:8, lineHeight:1.8 }}>
Update: <code style={{ color:'#4ecdc4' }}>docker compose up -d --build</code>
</div>
</Modal>
);
}
// ── Desktop Sidebar ───────────────────────────────────────────────────────────
function Sidebar({ active, setActive, user, onLogout, updateInfo, onUpdateClick }) {
const [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const NavBtn = ({ item, sub=false }) => {
const ic = active===item.id ? '#4ecdc4' : 'rgba(255,255,255,0.55)';
return (
<button onClick={()=>setActive(item.id)} title={collapsed ? item.label : undefined} style={{
width:'100%', display:'flex', alignItems:'center', gap:9,
padding: collapsed ? '8px 0' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius:7, border:'none',
background: active===item.id ? 'linear-gradient(90deg,rgba(78,205,196,0.14),rgba(78,205,196,0.02))' : 'transparent',
cursor:'pointer', fontSize:12,
fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft: active===item.id ? '2px solid #4ecdc4' : '2px solid transparent',
}}>
{!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 }}>
{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>}
</span>
{!collapsed && <span style={{ flex:1, color:ic }}>{item.label}</span>}
</button>
);
};
return (
<aside style={{ width:collapsed?56:210, minHeight:'100vh', background:'#0d0d0f',
borderRight:'1px solid rgba(255,255,255,0.06)', display:'flex', flexDirection:'column', flexShrink:0,
transition:'width 0.2s ease', overflow:'hidden', position:'relative' }}>
<div style={{ padding:'12px 8px', borderBottom:'1px solid rgba(255,255,255,0.06)', marginBottom:8 }}>
{collapsed ? (
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', gap:8 }}>
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14 }}></div>
<button onClick={()=>setCollapsed(false)} style={{
background:'rgba(78,205,196,0.2)', border:'1px solid rgba(78,205,196,0.5)',
borderRadius:6, color:'#4ecdc4', cursor:'pointer', padding:'4px 6px',
fontSize:14, lineHeight:1, fontWeight:700, width:'100%' }}></button>
</div>
) : (
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14, flexShrink:0 }}></div>
<div style={{flex:1,minWidth:0}}>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700, whiteSpace:'nowrap' }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
</div>
<button onClick={()=>setCollapsed(true)} style={{
background:'rgba(78,205,196,0.15)', border:'1px solid rgba(78,205,196,0.4)',
borderRadius:6, color:'#4ecdc4', cursor:'pointer', padding:'4px 8px', flexShrink:0,
fontSize:14, lineHeight:1, fontWeight:700 }}></button>
</div>
)}
</div>
<nav style={{ flex:1, padding:'0 8px' }}>
<NavBtn item={{ id:'dashboard', Icon:HomeIcon, label:'Dashboard' }} />
{/* Apps Sektion mit Gruppenüberschriften */}
<button onClick={()=>setAppsOpen(v=>!v)} title={collapsed?'Apps':undefined} style={{
width:'100%', display:'flex', alignItems:'center', gap:9,
padding: collapsed ? '9px 0' : '9px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius:7, border:'none', background:'transparent',
cursor:'pointer', fontSize:12,
fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft:'2px solid transparent',
}}>
<AppsIcon size={collapsed?18:15} color="rgba(255,255,255,0.6)"/>
{!collapsed && <span style={{ flex:1, color:'rgba(255,255,255,0.65)' }}>Apps</span>}
{!collapsed && <ChevronIcon size={13} color="rgba(255,255,255,0.35)" dir={appsOpen?'down':'right'}/>}
</button>
{appsOpen && getGroupedTools().map(([group, tools]) => (
<div key={group}>
{!collapsed && <div style={{ color:'rgba(78,205,196,0.85)', fontSize:9, fontFamily:'monospace',
letterSpacing:2, padding:'8px 12px 3px 12px' }}>{group.toUpperCase()}</div>}
{tools.map(t => <NavBtn key={t.id} item={t} sub />)}
</div>
))}
<NavBtn item={{ id:'admin', Icon:AdminIcon, label:'Einstellungen' }} />
</nav>
{updateInfo?.hasUpdate && (
<button onClick={onUpdateClick} style={{
margin:'0 8px 8px', background:'rgba(255,230,109,0.08)',
border:'1px solid rgba(255,230,109,0.3)', borderRadius:8,
padding:'8px 12px', cursor:'pointer', display:'flex', alignItems:'center', gap:8,
}}>
<span style={{ width:7, height:7, borderRadius:'50%', background:'#ffe66d', flexShrink:0, boxShadow:'0 0 6px #ffe66d', display:'inline-block' }} />
<span style={{ color:'#ffe66d', fontSize:11, fontFamily:'monospace' }}>Update</span>
<span style={{ color:'rgba(255,230,109,0.5)', fontSize:10, fontFamily:'monospace', marginLeft:'auto' }}>{updateInfo.latestVersion}</span>
</button>
)}
<div style={{ padding:'10px 12px 16px', borderTop:'1px solid rgba(255,255,255,0.06)' }}>
{!collapsed && <><div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
<div style={{ width:24, height:24, borderRadius:'50%', background:'linear-gradient(135deg,#ff6b9d,#4ecdc4)',
display:'flex', alignItems:'center', justifyContent:'center', overflow:'hidden', flexShrink:0 }}>
{localStorage.getItem('dd_avatar')
? <img src={localStorage.getItem('dd_avatar')} style={{width:'100%',height:'100%',objectFit:'cover'}} alt=""/>
: <UserIcon size={14} color="#0d0d0f"/>}
</div>
<div style={{ color:'#fff', fontSize:11, fontFamily:'monospace' }}>{user?.username}</div>
</div>
<button onClick={onLogout} style={{ ...S.btn('#ff6b9d',true), width:'100%', textAlign:'center' }}>Ausloggen</button></>}
{collapsed && <button onClick={onLogout} title="Ausloggen" style={{ background:'transparent', border:'none', cursor:'pointer', padding:'4px', width:'100%', display:'flex', justifyContent:'center' }}><LogOutIcon size={18} color="rgba(255,107,157,0.7)"/></button>}
</div>
</aside>
);
}
// ── Mobile Bottom Navigation ──────────────────────────────────────────────────
function BottomNav({ active, setActive, user, onLogout, updateInfo, onUpdateClick }) {
const [menuOpen, setMenuOpen] = useState(false);
const [appsOpen, setAppsOpen] = useState(false);
const NAV_H = 56;
const MI = ({Icon, icon, label, onClick, color='rgba(255,255,255,0.85)'}) => {
const Ic = Icon;
return (
<button onClick={onClick} style={{ width:'100%', padding:'11px 20px', background:'transparent',
border:'none', display:'flex', alignItems:'center', gap:12, cursor:'pointer' }}>
<span style={{flexShrink:0, width:22, display:'flex', alignItems:'center', justifyContent:'center'}}>
{Ic ? <Ic size={18} color={color}/> : <span style={{fontSize:17, color}}>{icon}</span>}
</span>
<span style={{color, fontFamily:'monospace', fontSize:13}}>{label}</span>
</button>
);
};
const Sheet = ({open, onClose, children}) => {
if (!open) return null;
const isDesktop = window.innerWidth >= 768;
if (isDesktop) return (
<div style={{ position:'fixed', inset:0, zIndex:4000, background:'rgba(0,0,0,0.6)',
display:'flex', alignItems:'center', justifyContent:'center', padding:24 }}
onClick={onClose}>
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.15)',
borderRadius:16, padding:'20px 0', width:'100%', maxWidth:380,
boxShadow:'0 24px 80px rgba(0,0,0,0.6)' }}
onClick={e=>e.stopPropagation()}>
{children}
</div>
</div>
);
return (
<div style={{ position:'fixed', inset:0, zIndex:4000, background:'rgba(0,0,0,0.6)' }}
onClick={onClose}>
<div style={{ position:'absolute', bottom:`calc(${NAV_H}px + env(safe-area-inset-bottom, 0px))`,
left:0, right:0, background:'#1a1a1e',
borderTop:'1px solid rgba(255,255,255,0.1)',
borderRadius:'16px 16px 0 0', paddingTop:8 }}
onClick={e=>e.stopPropagation()}>
<div style={{ width:36, height:4, background:'rgba(255,255,255,0.15)', borderRadius:2, margin:'0 auto 10px' }}/>
{children}
<div style={{height:'env(safe-area-inset-bottom, 0px)'}}/>
</div>
</div>
);
};
const mainNav = [
{ id:'dashboard', Icon:HomeIcon, label:'Home' },
{ id:'_apps', Icon:AppsIcon, label:'Apps' },
{ id:'admin', Icon:AdminIcon, label:'Einst.' },
{ id:'_mehr', Icon:MoreIcon, label:'Mehr' },
];
return (
<>
{/* Apps Sheet mit Gruppenüberschriften */}
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
<div style={{ paddingBottom:4 }}>
{getGroupedTools().map(([group, tools]) => (
<div key={group}>
<div style={{ color:'rgba(78,205,196,0.85)', fontSize:9, fontFamily:'monospace',
letterSpacing:2, padding:'12px 20px 5px' }}>{group.toUpperCase()}</div>
{tools.map(t => (
<MI key={t.id} Icon={t.Icon} label={t.label}
onClick={() => { setActive(t.id); setAppsOpen(false); }} />
))}
</div>
))}
</div>
</Sheet>
{/* Mehr Sheet */}
<Sheet open={menuOpen} onClose={()=>setMenuOpen(false)}>
{updateInfo?.hasUpdate && <MI Icon={UpdateIcon} label={`Update: ${updateInfo.latestVersion}`} onClick={()=>{setMenuOpen(false);onUpdateClick();}} color="#ffe66d"/>}
<div style={{height:1, background:'rgba(255,255,255,0.08)', margin:'4px 0'}}/>
<MI Icon={UserIcon} label={user?.username||''} onClick={()=>{}} color="rgba(255,255,255,0.35)"/>
<MI Icon={LogOutIcon} label="Ausloggen" onClick={()=>{onLogout();setMenuOpen(false);}} color="#ff6b9d"/>
</Sheet>
{/* Bottom Bar */}
<nav style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:3000,
background:'#0d0d0f', borderTop:'1px solid rgba(255,255,255,0.12)' }}>
<div style={{ display:'flex', height:NAV_H }}>
{mainNav.map(item => {
const isApps = item.id === '_apps';
const isMehr = item.id === '_mehr';
const isActive = isApps ? appsOpen : isMehr ? menuOpen : active === item.id;
return (
<button key={item.id}
onClick={() => {
if (isApps) { setAppsOpen(v=>!v); setMenuOpen(false); }
else if (isMehr) { setMenuOpen(v=>!v); setAppsOpen(false); }
else { setActive(item.id); setAppsOpen(false); setMenuOpen(false); }
}}
style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center',
justifyContent:'center', gap:3, background:'transparent', border:'none',
cursor:'pointer', position:'relative', minWidth:0, padding:'0 4px' }}>
{isMehr && updateInfo?.hasUpdate && (
<span style={{ position:'absolute', top:6, right:'calc(50% - 16px)',
width:8, height:8, borderRadius:'50%', background:'#ffe66d',
boxShadow:'0 0 6px #ffe66d', display:'inline-block' }} />
)}
{(() => { 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 ─────────────────────────────────────────────────────────
function QuickLinks({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [links, setLinks] = useState([]);
const [editMode, setEdit] = useState(false);
const [showForm, setForm] = useState(false);
const [form, setF] = useState({ title:'', url:'', icon:'🔗' });
const [editId, setEditId] = useState(null);
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
const getFavicon = url => {
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
catch { return null; }
};
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
let url = form.url.trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
// Auto favicon if user left default
const icon = form.icon === '🔗' ? (getFavicon(url) || '🔗') : form.icon;
try {
if (editId) {
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, icon, url} });
setLinks(p => p.map(l => l.id===editId ? u : l));
} else {
const n = await api('/dashboard/links', { body:{...form, icon, url} });
setLinks(p => [...p, n]);
}
toast(editId ? 'Aktualisiert' : 'Gespeichert');
setF({ title:'', url:'', icon:'🔗' }); setForm(false); setEditId(null);
} catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
if (!await confirm('Link wirklich löschen?')) return;
try { await api(`/dashboard/links/${id}`, { method:'DELETE' }); setLinks(p => p.filter(l => l.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message, 'error'); }
};
return (
<div style={{ ...S.card, marginBottom:10 }}>
<ConfirmDialog/>
<div style={{ marginBottom:10 }}>
<div style={S.head}>SCHNELLZUGRIFF</div>
</div>
{/* Link-Grid */}
{links.length === 0 && !editMode
? <div style={{ color:'rgba(255,255,255,0.5)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>Links können unter Einstellungen Dashboard hinzugefügt werden.</div>
: <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
{links.map(l => (
<div key={l.id} style={{ position:'relative' }}>
<a href={l.url} target="_blank" rel="noopener noreferrer"
style={{ display:'flex', flexDirection:'column', alignItems:'center', gap:4,
padding:'10px 14px', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
textDecoration:'none', minWidth:60, cursor:'pointer' }}>
{l.icon && l.icon.startsWith('http')
? <img src={l.icon} alt="" style={{width:26,height:26,objectFit:'contain'}} onError={e=>e.target.style.display='none'}/>
: <span style={{ fontSize:26 }}>{l.icon}</span>}
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:9, fontFamily:'monospace',
maxWidth:64, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>
</a>
</div>
))}
</div>
}
{/* Formular */}
{showForm && (
<div style={{ marginTop:12, padding:12, background:'rgba(0,0,0,0.25)', border:'1px solid rgba(255,255,255,0.06)', borderRadius:10 }}>
{/* Icon-Picker */}
<div style={{ marginBottom:10 }}>
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
{ICONS.map(ic => (
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
background: form.icon===ic ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
}}>{ic}</button>
))}
</div>
</div>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginBottom:10 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>TITEL</label>
<input value={form.title} onChange={e => setF(f => ({...f, title:e.target.value}))}
style={{ ...S.inp, fontSize:16 }} placeholder="Mein Link" />
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>URL</label>
<input value={form.url} onChange={e => setF(f => ({...f, url:e.target.value}))}
onKeyDown={e => e.key==='Enter' && save()}
style={{ ...S.inp, fontSize:16 }} placeholder="https://…" inputMode="url" autoCapitalize="none" />
</div>
</div>
<div style={{ display:'flex', gap:8 }}>
<button onClick={save} style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center' }}>{editId ? '✓ Aktualisieren' : '✓ Speichern'}</button>
<button onClick={() => { setForm(false); setEditId(null); setF({ title:'', url:'', icon:'🔗' }); }} style={{ ...S.btn('#ff6b9d'), flex:1, textAlign:'center' }}>Abbrechen</button>
</div>
</div>
)}
</div>
);
}
function TodoList({ toast }) {
const [items, setItems] = useState([]);
const [text, setText] = useState('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => { api('/dashboard/todos').then(setItems).catch(() => {}); }, []);
const add = async () => {
if (!text.trim()) return;
try { const n = await api('/dashboard/todos', { body:{ text:text.trim() } }); setItems(p => [...p, n]); setText(''); }
catch(e) { toast(e.message, 'error'); }
};
const toggle = async item => {
try { const u = await api(`/dashboard/todos/${item.id}`, { method:'PUT', body:{ done:!item.done } }); setItems(p => p.map(i => i.id===item.id ? u : i)); }
catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
try { await api(`/dashboard/todos/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id!==id)); }
catch(e) { toast(e.message, 'error'); }
};
const open = items.filter(i => !i.done);
const done = items.filter(i => i.done);
return (
<div style={{ ...S.card, marginBottom:10 }}>
<button onClick={()=>setIsOpen(v=>!v)} style={{width:'100%',background:'transparent',border:'none',
display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer',padding:0}}>
<div style={S.head}>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}>NOTIZEN</div>
{!isOpen && content.length > 0 && (
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>
{content.length} Zeichen
</span>
)}
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11,display:'inline-block',marginLeft:'auto',
transform:isOpen?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}></span>
</button>
{isOpen && <div style={{ color: saved ? 'rgba(78,205,196,0.5)' : 'rgba(255,230,109,0.6)',
fontSize:10, fontFamily:'monospace', marginTop:4, marginBottom:6, textAlign:'right' }}>
{saved ? (saveTime ? `${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : '✓') : '…'}
</div>}
{isOpen && <textarea value={content} onChange={e => change(e.target.value)}
placeholder="Notizen, IPs, Zugangsdaten…"
style={{ ...S.inp, width:'100%', minHeight:140, resize:'vertical', lineHeight:1.7, padding:'10px 12px', fontSize:14 }} />}
</div>
);
}
// ── Bestell-Statistik Widget ─────────────────────────────────────────────────
function BestellStats() {
const [stats, setStats] = useState(null);
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = () => {
if (loaded) return;
api('/dashboard/stats').then(s => { setStats(s); setLoaded(true); }).catch(() => {});
};
const toggle = () => { setOpen(v => !v); if (!open) load(); };
const Row = ({label, val, color='#fff'}) => (
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:12}}>{label}</span>
<span style={{color,fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>{val}</span>
</div>
);
return (
<div style={{...S.card,marginBottom:10}}>
<button onClick={toggle} style={{width:'100%',background:'transparent',border:'none',
display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer',padding:0}}>
<div style={S.head}>3D-DRUCK STATISTIK</div>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11,display:'inline-block',
transform:open?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}></span>
</button>
{open && (
<div style={{marginTop:12}}>
{!stats ? (
<div style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:12}}>Lädt</div>
) : (
<>
<div style={{marginBottom:12}}>
<div style={{...S.head,marginBottom:6}}>BESTELLUNGEN</div>
<Row label="Gesamt" val={stats.totalOrders}/>
<Row label="Diesen Monat" val={stats.ordersThisMonth} color="#4ecdc4"/>
<Row label="Warteliste" val={stats.byStatus.warteliste||0} color="#ffe66d"/>
<Row label="In Arbeit" val={stats.byStatus.in_arbeit||0} color="#4ecdc4"/>
<Row label="Fertig (unbezahlt)" val={stats.byStatus.fertig||0} color="#6bcb77"/>
<Row label="Bezahlt" val={stats.bezahltOrders} color="#c084fc"/>
</div>
<div>
<div style={{...S.head,marginBottom:6}}>EINNAHMEN</div>
<Row label="Eingenommen (bezahlt)" val={`${stats.totalRevenue.toFixed(2)}`} color="#6bcb77"/>
<Row label="Offen (in Arbeit/Fertig)" val={`${stats.offeneRevenue.toFixed(2)}`} color="#ffe66d"/>
<Row label="Archiv-Einträge" val={stats.totalCalcs} color="rgba(255,255,255,0.5)"/>
</div>
</>
)}
</div>
)}
</div>
);
}
function Dashboard({ toast, mobile }) {
return (
<div style={{ padding: mobile ? '14px 12px 90px' : '36px 44px', maxWidth:1100 }}>
<div style={{ marginBottom:16 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile ? 17 : 22, margin:0 }}>Dashboard</h1>
<p style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginTop:3 }}>
{new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })}
</p>
</div>
<QuickLinks toast={toast} mobile={mobile} />
<CalendarWidget/>
<BestellStats/>
<TodoList toast={toast} />
<Notepad toast={toast} />
</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 { 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'); }
};
return (
<div>
<ConfirmDialog/>
{/* Link-Liste */}
{links.length > 0 && (
<div style={{ marginBottom:12 }}>
{links.map(l => (
<div key={l.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<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 ────────────────────────────────────────────────────────
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)',
}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{u.username}</span>
<span style={{
marginLeft:8, 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>
</div>
<div style={{display:'flex',gap:5}}>
<button onClick={()=>{setResetId(u.id);setResetPw('');}}
style={{...S.btn('#ffe66d',true),fontSize:10}}>🔑 PW</button>
<button onClick={()=>del(u.id)} style={{...S.btn('#ff6b9d',true)}}>
<TrashIcon size={13} color="#ff6b9d"/>
</button>
</div>
</div>
{/* Statistik */}
<div style={{display:'flex',flexWrap:'wrap',gap:6}}>
{[
['◈ Archiv', u.archiv],
['📦 Bestellungen', u.bestellung],
['✓ 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>
);
}
// Field außerhalb definiert → Tastatur bleibt beim Tippen erhalten
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
<div style={{ marginBottom:12 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>{label}</label>
<input type={type} value={value} onChange={onChange} placeholder={placeholder}
autoCapitalize={autoCapitalize}
style={{ ...S.inp, fontSize:16, padding:'11px 12px' }} />
</div>
);
function AdminPanel({ toast, mobile, user }) {
const [section, setSection] = useState('profil');
const [pw, setPw] = useState({ current:'', newPw:'', confirm:'' });
const [avatar, setAvatar] = useState(localStorage.getItem('dd_avatar') || null);
const [un, setUn] = useState({ newUsername:'', unPw:'' });
const [file, setFile] = useState(null);
const [busy, setBusy] = useState({ pw:false, un:false, restore:false });
const set = (key, val) => setBusy(b => ({...b, [key]:val}));
const changePw = async () => {
if (pw.newPw !== pw.confirm) { toast('Passwörter stimmen nicht überein', 'error'); return; }
if (pw.newPw.length < 8) { toast('Mindestens 8 Zeichen', 'error'); return; }
set('pw', true);
try { await api('/auth/change-password', { body:{ currentPassword:pw.current, newPassword:pw.newPw } }); toast('Passwort geändert'); setPw({ current:'', newPw:'', confirm:'' }); }
catch(e) { toast(e.message, 'error'); } finally { set('pw', false); }
};
const changeUn = async () => {
if (!un.newUsername.trim()) { toast('Benutzername eingeben', 'error'); return; }
set('un', true);
try {
await api('/auth/change-username', { body:{ newUsername:un.newUsername.trim(), password:un.unPw } });
toast('Benutzername geändert bitte neu einloggen');
setUn({ newUsername:'', unPw:'' });
setTimeout(() => { localStorage.removeItem('sk_token'); window.location.reload(); }, 1800);
} catch(e) { toast(e.message, 'error'); } finally { set('un', false); }
};
const backup = async () => {
try {
const blob = await api('/admin/backup');
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: `dickendock-backup-${new Date().toISOString().split('T')[0]}.db`
});
a.click(); URL.revokeObjectURL(a.href); toast('Backup heruntergeladen');
} catch(e) { toast(e.message, 'error'); }
};
const restore = async () => {
if (!file) { toast('Datei auswählen', 'error'); return; }
if (!window.confirm('⚠️ Aktuelle Datenbank wird ersetzt. Fortfahren?')) return;
set('restore', true);
try {
const f = new FormData(); f.append('database', file);
await api('/admin/restore', { method:'POST', body:f, isFile:true });
toast('Wiederhergestellt'); setFile(null);
} catch(e) { toast(e.message, 'error'); } finally { set('restore', false); }
};
return (
<div style={{ padding: mobile ? '14px 12px 90px' : '36px 44px', maxWidth:560 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?17:22, marginBottom:16 }}>Einstellungen</h1>
<div style={{ display:'flex', gap:6, marginBottom:18, flexWrap:'wrap' }}>
{[['profil','Profil'],['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>
</div>
)}
{section === 'benutzer' && user?.role==='admin' && (
<div><UserManagement toast={toast}/></div>
)}
{section === 'dashboard' && (
<div>
<Sec title="SCHNELLZUGRIFF">
<QuickLinksSettings toast={toast}/>
</Sec>
<Sec title="KALENDER (iCal)">
<CalendarSettings toast={toast}/>
</Sec>
{user?.role==='admin' && <Sec title="DATEI-MANAGER LIMITS">
<DateiSettings toast={toast}/>
</Sec>}
</div>
)}
{section === 'backup' && (
<div>
<Sec title="DATENBANK">
<p style={{ color:'rgba(255,255,255,0.55)', fontSize:12, fontFamily:'monospace', marginBottom:12, lineHeight:1.6 }}>
Vollständige SQLite-Datenbank sichern oder wiederherstellen.
</p>
<button onClick={backup} style={{ ...S.btn('#ffe66d'), width:'100%', textAlign:'center', padding:'11px 0', marginBottom:12 }}>
Backup herunterladen
</button>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SICHERUNG EINSPIELEN (.db)</label>
<div style={{ display:'flex', gap:8 }}>
<input type="file" accept=".db" onChange={e => setFile(e.target.files[0])}
style={{ ...S.inp, flex:1, padding:'9px 10px', cursor:'pointer', fontSize:13 }} />
<button onClick={restore} disabled={busy.restore || !file}
style={{ ...S.btn('#ff6b9d'), opacity: busy.restore || !file ? 0.4 : 1, padding:'0 14px', flexShrink:0 }}>
{busy.restore ? '…' : '↑'}
</button>
</div>
{file && <div style={{ color:'rgba(255,255,255,0.55)', fontSize:10, fontFamily:'monospace', marginTop:5 }}>{file.name}</div>}
</Sec>
</div>
)}
</div>
);
}
// ── App Shell ─────────────────────────────────────────────────────────────────
export default function App() {
const mobile = useIsMobile();
const [splash, setSplash] = useState(() => {
// Only show splash in standalone PWA mode, and only once per session
return window.matchMedia('(display-mode: standalone)').matches && !sessionStorage.getItem('splash_shown');
});
const [user,setUser]=useState(null);
const [active,setActive]=useState('dashboard');
const [toastMsg,setToastMsg]=useState({msg:'',type:'success'});
const [updateInfo,setUpdateInfo]=useState(null);
const [showUpd,setShowUpd]=useState(false);
useEffect(()=>{
const token=localStorage.getItem('sk_token');
if(!token)return;
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');}
},[]);
useEffect(()=>{
if(!user)return;
const check=()=>api('/system/update-check').then(setUpdateInfo).catch(()=>{});
check();
const t=setInterval(check,5*60*1000);
return()=>clearInterval(t);
},[user]);
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(!user) return <Login onLogin={u=>setUser(u)}/>;
const activeTool = TOOLS.find(t=>t.id===active);
// Mobile padding bottom für fixed nav
const mainStyle = { flex:1, overflowY:'auto', ...(mobile ? { paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' } : {}) };
return(
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#111114;-webkit-tap-highlight-color:transparent;}
input,textarea,select{-webkit-appearance:none;appearance:none;}
input[type=number]::-webkit-inner-spin-button{opacity:0.25;}
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
::-webkit-scrollbar{width:3px;}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:2px;}
select option,textarea{background:#111114;}
`}</style>
<div style={{display:'flex',minHeight:'100vh',background:'#111114'}}>
{!mobile && (
<Sidebar active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}/>
)}
<main style={mainStyle}>
{active==='dashboard' && <Dashboard toast={toast} mobile={mobile}/>}
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile}/>}
</main>
</div>
{mobile && (
<BottomNav active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}/>
)}
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
{showUpd&&updateInfo&&<UpdateModal data={updateInfo} onClose={()=>setShowUpd(false)}/>}
</>
);
}