Files
dickendock/frontend/src/App.jsx

725 lines
38 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';
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:'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 [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.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 Gruppenüberschriften */}
<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 && getGroupedTools().map(([group, tools]) => (
<div key={group}>
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:9, fontFamily:'monospace',
letterSpacing:2, padding:'6px 12px 3px 28px' }}>{group.toUpperCase()}</div>
{tools.map(t => <NavBtn key={t.id} item={t} indent />)}
</div>
))}
{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); }
};
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)}/>}
</>
);
}