Dateien nach "frontend/src" hochladen

This commit is contained in:
2026-05-25 20:45:10 +02:00
parent 368f5d228f
commit e342b20cf0

713
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,713 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from './lib.js';
import { TOOLS } from './toolRegistry.js';
const ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'];
// ── Responsive Hook ───────────────────────────────────────────────────────────
function useIsMobile() {
const [mobile, setMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const fn = () => setMobile(window.innerWidth < 768);
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:'flex-end', justifyContent:'center' }}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.1)',
borderRadius:'16px 16px 0 0', padding:'20px 20px 40px', width:'100%',
maxWidth:600, maxHeight:'85vh', overflowY:'auto' }}>
<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>
);
}
// ── Login ─────────────────────────────────────────────────────────────────────
function Login({ onLogin }) {
const mobile = useIsMobile();
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={{ minHeight:'100vh', minHeight:'100dvh', background:'#0d0d0f', display:'flex', alignItems:'center', justifyContent:'center', padding: mobile ? '0' : '20px' }}>
<div style={{ width:'100%', maxWidth:360, background:'#0d0d0f', padding: mobile ? '40px 24px' : '32px', ...(mobile ? {} : S.card) }}>
<div style={{ textAlign:'center', marginBottom:32 }}>
<div style={{ fontSize:40 }}></div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:20, fontWeight:700, marginTop:10 }}>
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:16 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>{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:'12px 14px' }} autoCapitalize="none" />
</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:'14px 0', color:'#0d0d0f', fontFamily:"'Space Mono',monospace",
fontWeight:700, fontSize:15, cursor:busy?'default':'pointer', opacity:busy?0.7:1, marginTop:4,
}}>{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.25)', 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 NavBtn = ({ item, indent=false }) => (
<button onClick={()=>setActive(item.id)} style={{
width:'100%', display:'flex', alignItems:'center', gap:9,
padding: indent ? '8px 12px 8px 28px' : '9px 12px',
borderRadius:7, border:'none',
background: active===item.id ? 'linear-gradient(90deg,rgba(78,205,196,0.14),rgba(78,205,196,0.02))' : 'transparent',
color: active===item.id ? '#4ecdc4' : indent ? 'rgba(255,255,255,0.5)' : 'rgba(255,255,255,0.65)',
cursor:'pointer', fontSize: indent ? 11 : 12,
fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft: active===item.id ? '2px solid #4ecdc4' : '2px solid transparent',
}}>
<span style={{ fontSize: indent ? 13 : 14 }}>{item.icon}</span>
<span style={{ flex:1 }}>{item.label}</span>
</button>
);
return (
<aside style={{ width:210, minHeight:'100vh', background:'#0d0d0f',
borderRight:'1px solid rgba(255,255,255,0.06)', display:'flex', flexDirection:'column', flexShrink:0 }}>
<div style={{ padding:'22px 18px 16px', borderBottom:'1px solid rgba(255,255,255,0.06)', marginBottom:8 }}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14 }}></div>
<div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
</div>
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace' }}>v1.0.0</div>
</div>
</div>
</div>
<nav style={{ flex:1, padding:'0 8px' }}>
<NavBtn item={{ id:'dashboard', icon:'⬡', label:'Dashboard' }} />
{/* Apps Sektion mit Unterpunkten */}
<button onClick={()=>setAppsOpen(v=>!v)} style={{
width:'100%', display:'flex', alignItems:'center', gap:9, padding:'9px 12px',
borderRadius:7, border:'none', background:'transparent',
color:'rgba(255,255,255,0.65)', cursor:'pointer', fontSize:12,
fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft:'2px solid transparent',
}}>
<span style={{ fontSize:14 }}></span>
<span style={{ flex:1 }}>Apps</span>
<span style={{ fontSize:10, opacity:0.5, transform:appsOpen?'rotate(90deg)':'rotate(0)', transition:'transform 0.2s', display:'inline-block' }}></span>
</button>
{appsOpen && TOOLS.map(t => <NavBtn key={t.id} item={t} indent />)}
{user?.role==='admin' && <NavBtn item={{ id:'admin', icon:'⚙', label:'Admin' }} />}
</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)' }}>
<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', fontSize:10 }}>👤</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>
</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, label, onClick, color='#fff'}) => (
<button onClick={onClick} style={{ width:'100%', padding:'14px 20px', background:'transparent',
border:'none', borderBottom:'1px solid rgba(255,255,255,0.05)',
display:'flex', alignItems:'center', gap:14, cursor:'pointer' }}>
<span style={{fontSize:20}}>{icon}</span>
<span style={{color, fontFamily:'monospace', fontSize:14}}>{label}</span>
</button>
);
const Sheet = ({open, onClose, children}) => !open ? null : (
<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:'⬡', label:'Home' },
{ id:'_apps', icon:'◫', label:'Apps' },
...(user?.role==='admin' ? [{ id:'admin', icon:'⚙', label:'Admin' }] : []),
{ id:'_mehr', icon:'⋯', label:'Mehr' },
];
return (
<>
{/* Apps Sheet */}
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
<div style={{ padding:'0 0 4px' }}>
<div style={{ color:'rgba(255,255,255,0.3)', fontSize:10, fontFamily:'monospace', letterSpacing:2, padding:'4px 20px 10px' }}>APPS</div>
{TOOLS.map(t => <MI key={t.id} icon={t.icon} label={t.label} onClick={()=>{setActive(t.id);setAppsOpen(false);}}/>)}
</div>
</Sheet>
{/* Mehr Sheet */}
<Sheet open={menuOpen} onClose={()=>setMenuOpen(false)}>
{updateInfo?.hasUpdate && <MI icon="📦" 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="👤" label={user?.username||''} onClick={()=>{}} color="rgba(255,255,255,0.35)"/>
<MI icon="🚪" 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' }} />
)}
<span style={{ fontSize:20, lineHeight:1 }}>{item.icon}</span>
<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 [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 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;
try {
if (editId) {
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, url} });
setLinks(p => p.map(l => l.id===editId ? u : l));
} else {
const n = await api('/dashboard/links', { body:{...form, 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 => {
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 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:10 }}>
<div style={S.head}>SCHNELLZUGRIFF</div>
<div style={{ display:'flex', gap:6 }}>
{editMode && <button onClick={() => { setForm(true); setEditId(null); setF({ title:'', url:'', icon:'🔗' }); }} style={S.btn('#4ecdc4', true)}>+ Link</button>}
<button onClick={() => { setEdit(v => !v); setForm(false); }} style={S.btn(editMode ? '#ff6b9d' : 'rgba(255,255,255,0.4)', true)}>
{editMode ? '✓ Fertig' : '✎'}
</button>
</div>
</div>
{/* Link-Grid */}
{links.length === 0 && !editMode
? <div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>Tippe auf "✎" um Links hinzuzufügen.</div>
: <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
{links.map(l => (
<div key={l.id} style={{ position:'relative' }}>
{editMode && (
<div style={{ position:'absolute', top:-6, right:-6, display:'flex', gap:2, zIndex:1 }}>
<button onClick={() => { setF({ title:l.title, url:l.url, icon:l.icon }); setEditId(l.id); setForm(true); }}
style={{ width:20, height:20, borderRadius:'50%', background:'#4ecdc4', border:'none', color:'#0d0d0f', fontSize:10, cursor:'pointer' }}></button>
<button onClick={() => del(l.id)}
style={{ width:20, height:20, borderRadius:'50%', background:'#ff6b9d', border:'none', color:'#0d0d0f', fontSize:11, cursor:'pointer' }}></button>
</div>
)}
<a href={l.url} target="_blank" rel="noopener noreferrer"
onClick={editMode ? e => e.preventDefault() : undefined}
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:editMode ? 'default' : 'pointer' }}>
<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('');
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 }}>
<div style={S.head}>TO-DO</div>
<div style={{ display:'flex', gap:6, marginBottom: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.15)', 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.2)', 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.2)', 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 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 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:8 }}>
<div style={S.head}>NOTIZEN</div>
<div style={{ color: saved ? 'rgba(78,205,196,0.5)' : 'rgba(255,230,109,0.6)', fontSize:10, fontFamily:'monospace' }}>
{saved ? (saveTime ? `${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : '✓') : '…'}
</div>
</div>
<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>
);
}
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.25)', fontFamily:'monospace', fontSize:10, marginTop:3 }}>
{new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })}
</p>
</div>
<QuickLinks toast={toast} mobile={mobile} />
<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>
);
// 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 }) {
const [pw, setPw] = useState({ current:'', newPw:'', confirm:'' });
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); }
};
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>
);
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:18 }}>Admin</h1>
<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="DATENBANK">
<p style={{ color:'rgba(255,255,255,0.3)', 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.25)', fontSize:10, fontFamily:'monospace', marginTop:5 }}>{file.name}</div>}
</Sec>
</div>
);
}
// ── App Shell ─────────────────────────────────────────────────────────────────
export default function App() {
const mobile = useIsMobile();
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(!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' && user?.role==='admin' && <AdminPanel toast={toast} mobile={mobile}/>}
{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)}/>}
</>
);
}