Files
dickendock/frontend/App-4.jsx

557 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 } 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 [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', background:'#0d0d0f', display:'flex', alignItems:'center', justifyContent:'center', padding:20 }}>
<div style={{ width:'100%', maxWidth:360, ...S.card, padding:32 }}>
<div style={{ textAlign:'center', marginBottom:28 }}>
<div style={{ fontSize:36 }}></div>
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:18, fontWeight:700, marginTop:8 }}>
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:3 }}>{l}</label>
<input type={t} value={v} onChange={e=>s(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&go()}
style={{ ...S.inp, fontSize:16 }} 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:10, padding:'13px 0', color:'#0d0d0f', fontFamily:"'Space Mono',monospace",
fontWeight:700, fontSize:14, cursor:busy?'default':'pointer', opacity:busy?0.7:1,
}}>{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 allNav = [
{ id:'dashboard', icon:'⬡', label:'Dashboard' },
...TOOLS.map(t => ({ id:t.id, icon:t.icon, label:t.label })),
...(user?.role==='admin' ? [{ id:'admin', icon:'⚙', label:'Admin' }] : []),
];
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' }}>
{allNav.map(item => (
<button key={item.id} onClick={()=>setActive(item.id)} style={{
width:'100%', display:'flex', alignItems:'center', gap:9, padding:'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' : 'rgba(255,255,255,0.6)',
cursor:'pointer', fontSize:12, fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
borderLeft: active===item.id ? '2px solid #4ecdc4' : '2px solid transparent',
}}>
<span style={{ fontSize:14 }}>{item.icon}</span>
<span style={{ flex:1 }}>{item.label}</span>
</button>
))}
</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 NAV_H = 56;
const mainNav = [
{ id:'dashboard', icon:'⬡', label:'Home' },
...TOOLS.slice(0,3).map(t => ({ id:t.id, icon:t.icon, label:t.navLabel || t.label })),
{ id:'_menu', icon:'⋯', label:'Mehr' },
];
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>
);
return (
<>
{menuOpen && (
<div style={{ position:'fixed', inset:0, zIndex:4000, background:'rgba(0,0,0,0.6)' }}
onClick={()=>setMenuOpen(false)}>
<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' }}/>
{updateInfo?.hasUpdate && <MI icon="📦" label={`Update: ${updateInfo.latestVersion}`} onClick={()=>{setMenuOpen(false);onUpdateClick();}} color="#ffe66d"/>}
{TOOLS.slice(3).map(t=><MI key={t.id} icon={t.icon} label={t.label} onClick={()=>{setActive(t.id);setMenuOpen(false);}}/>)}
{user?.role==='admin' && <MI icon="⚙" label="Admin" onClick={()=>{setActive('admin');setMenuOpen(false);}}/>}
<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"/>
<div style={{height:'env(safe-area-inset-bottom, 0px)'}}/>
</div>
</div>
)}
<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 isMenu = item.id === '_menu';
const isActive = isMenu ? menuOpen : active === item.id;
return (
<button key={item.id}
onClick={()=>isMenu?setMenuOpen(v=>!v):(setActive(item.id),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' }}>
{isMenu && 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 && !isMenu && (
<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:12 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12 }}>
<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>
<div style={{ display:'flex', flexWrap:'wrap', gap:8, minHeight:44 }}>
{links.length===0&&!editMode&&<div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace' }}>Tippe auf "✎" um Links hinzuzufügen.</div>}
{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:18,height:18,borderRadius:'50%',background:'#4ecdc4',border:'none',color:'#0d0d0f',fontSize:9,cursor:'pointer' }}></button>
<button onClick={()=>del(l.id)} style={{ width:18,height:18,borderRadius:'50%',background:'#ff6b9d',border:'none',color:'#0d0d0f',fontSize:10,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 12px',
background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:10,textDecoration:'none',minWidth:64,cursor:editMode?'default':'pointer' }}>
<span style={{ fontSize:24 }}>{l.icon}</span>
<span style={{ color:'rgba(255,255,255,0.7)',fontSize:9,fontFamily:'monospace',maxWidth:64,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>{l.title}</span>
</a>
</div>
))}
</div>
{showForm&&(
<div style={{ marginTop:12,padding:12,background:'rgba(0,0,0,0.2)',border:'1px solid rgba(255,255,255,0.06)',borderRadius:10 }}>
<div style={{ marginBottom:10 }}>
<div style={S.head}>ICON</div>
<div style={{ display:'flex',flexWrap:'wrap',gap:4 }}>
{ICONS.map(ic=>(
<button key={ic} onClick={()=>setF(f=>({...f,icon:ic}))} style={{ width:32,height:32,borderRadius:6,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)',cursor:'pointer',fontSize:16 }}>{ic}</button>
))}
</div>
</div>
<div style={{ marginBottom:8 }}>
<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 style={{ marginBottom:10 }}>
<label style={{ ...S.head, display:'block', marginBottom:3 }}>URL</label>
<input value={form.url} onChange={e=>setF(f=>({...f,url:e.target.value}))} style={{ ...S.inp,fontSize:16 }} placeholder="https://example.com" inputMode="url" autoCapitalize="none" />
</div>
<div style={{ display:'flex',gap:8 }}>
<button onClick={save} style={S.btn('#4ecdc4')}>{editId?'✓ Aktualisieren':'✓ Speichern'}</button>
<button onClick={()=>{setForm(false);setEditId(null);setF({title:'',url:'',icon:'🔗'});}} style={S.btn('#ff6b9d')}>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), done=items.filter(i=>i.done);
return(
<div style={{...S.card,marginBottom:12}}>
<div style={S.head}>TO-DO LISTE</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:'8px 14px',fontSize:18}}>+</button>
</div>
<div style={{maxHeight:240,overflowY:'auto'}}>
{open.map(item=>(
<div key={item.id} style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0',borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<button onClick={()=>toggle(item)} style={{width:20,height:20,borderRadius:4,border:'1px solid rgba(78,205,196,0.4)',background:'transparent',cursor:'pointer',flexShrink:0}}/>
<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:16,padding:'0 4px'}}></button>
</div>
))}
{done.length>0&&<>
<div style={{...S.head,padding:'8px 0 4px',marginBottom:0}}>ERLEDIGT</div>
{done.map(item=>(
<div key={item.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',opacity:0.4}}>
<button onClick={()=>toggle(item)} style={{width:20,height:20,borderRadius:4,border:'1px solid rgba(78,205,196,0.3)',background:'rgba(78,205,196,0.15)',cursor:'pointer',flexShrink:0,fontSize:11,color:'#4ecdc4'}}></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:16}}></button>
</div>
))}
</>}
{items.length===0&&<div style={{color:'rgba(255,255,255,0.15)',fontSize:12,fontFamily:'monospace',padding:'8px 0'}}>Noch keine Aufgaben.</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:10}}>
<div style={S.head}>NOTIZBLOCK</div>
<div style={{color:saved?'rgba(78,205,196,0.5)':'rgba(255,230,109,0.5)',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:160,resize:'none',lineHeight:1.7,padding:'10px 12px',fontSize:13}}/>
</div>
);
}
function Dashboard({ toast, mobile }) {
return(
<div style={{padding: mobile ? '16px 14px 80px' : '36px 44px', maxWidth:1100}}>
<div style={{marginBottom:18}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?18:22,margin:0}}>Dashboard</h1>
<p style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10,marginTop:4}}>
{new Date().toLocaleDateString('de-DE',{weekday:'long',year:'numeric',month:'long',day:'numeric'})}
</p>
</div>
<QuickLinks toast={toast} mobile={mobile}/>
<TodoList toast={toast}/>
<Notepad toast={toast}/>
</div>
);
}
// ── Admin Panel ───────────────────────────────────────────────────────────────
function AdminPanel({ toast, mobile }) {
const [pw,setPw]=useState({current:'',newPw:'',confirm:''}); const [busy,setBusy]=useState(false);
const [file,setFile]=useState(null); const [restoring,setRestoring]=useState(false);
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;}
setBusy(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{setBusy(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;
setRestoring(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{setRestoring(false);}
};
const Sec=({title,children})=><div style={{...S.card,marginBottom:12}}><div style={S.head}>{title}</div>{children}</div>;
return(
<div style={{padding: mobile ? '16px 14px 80px' : '36px 44px', maxWidth:640}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?18:22,marginBottom:20}}>Admin-Bereich</h1>
<Sec title="PASSWORT ÄNDERN">
{[['Aktuelles Passwort','current'],['Neues Passwort','newPw'],['Bestätigen','confirm']].map(([l,k])=>(
<div key={k} style={{marginBottom:12}}>
<label style={{...S.head,display:'block',marginBottom:3}}>{l.toUpperCase()}</label>
<input type="password" value={pw[k]} onChange={e=>setPw(p=>({...p,[k]:e.target.value}))} style={{...S.inp,fontSize:16}}/>
</div>
))}
<button onClick={changePw} disabled={busy} style={S.btn('#4ecdc4')}>{busy?'…':'✓ Passwort ändern'}</button>
</Sec>
<Sec title="DATENBANK-BACKUP">
<p style={{color:'rgba(255,255,255,0.3)',fontSize:12,fontFamily:'monospace',marginBottom:12,lineHeight:1.6}}>Vollständige SQLite-Datenbank herunterladen.</p>
<button onClick={backup} style={S.btn('#ffe66d')}> Backup herunterladen</button>
</Sec>
<Sec title="SICHERUNG EINSPIELEN">
<p style={{color:'rgba(255,255,255,0.3)',fontSize:12,fontFamily:'monospace',marginBottom:12,lineHeight:1.6}}> Aktuelle Datenbank wird vollständig ersetzt.</p>
<div style={{marginBottom:10}}>
<label style={{...S.head,display:'block',marginBottom:3}}>BACKUP-DATEI (.db)</label>
<input type="file" accept=".db" onChange={e=>setFile(e.target.files[0])} style={{...S.inp,padding:'6px 10px',cursor:'pointer'}}/>
</div>
<button onClick={restore} disabled={restoring||!file} style={{...S.btn('#ff6b9d'),opacity:restoring||!file?0.45:1}}>{restoring?'…':'↑ Einspielen'}</button>
{file&&<div style={{color:'rgba(255,255,255,0.25)',fontSize:10,fontFamily:'monospace',marginTop:6}}>{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)}/>}
</>
);
}