import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from './lib.js';
import { TOOLS, getGroupedTools } from './toolRegistry.js';
import CalendarWidget, { CalendarSettings } from './calendar.jsx';
import { DateiSettings } from './tools/dateien.jsx';
import { useConfirm } from './confirm.jsx';
import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon, MessageIcon } from './icons.jsx';
import { getOrCreateKeyPair, getPublicKeyJwk, getFingerprint, exportEncrypted, importEncrypted, generateNewKeyPair } from './crypto.js';
const CONST_ICONS = ['๐','๐','๐','๐','๐ ','๐ง','๐','๐พ','๐ฅ','๐ก','๐','๐','๐ฏ','โก','๐ ','๐ง','๐ฑ','๐ฎ','๐','๐ก'];
// Schlรผsselpaar beim Login generieren und public key registrieren (fire & forget)
async function initNachrichtenKeys() {
const LS_KEY = 'dd_msg_keypair';
let pubJwk;
const stored = localStorage.getItem(LS_KEY);
if (stored) {
try { pubJwk = JSON.parse(stored).pub; } catch { localStorage.removeItem(LS_KEY); }
}
if (!pubJwk) {
const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']);
pubJwk = await crypto.subtle.exportKey('jwk', kp.publicKey);
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
localStorage.setItem(LS_KEY, JSON.stringify({ pub: pubJwk, priv }));
}
try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(pubJwk) } }); } catch {}
}
// โโ Responsive Hook โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function useIsMobile() {
const [mobile, setMobile] = useState(window.innerWidth < 768);
useEffect(() => {
// Nur auf Breite reagieren, nicht auf Keyboard-Open (Hรถhenรคnderung)
const fn = () => {
const isMob = window.innerWidth < 768;
setMobile(prev => prev === isMob ? prev : isMob);
};
window.addEventListener('resize', fn);
return () => window.removeEventListener('resize', fn);
}, []);
return mobile;
}
// โโ Toast โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function Toast({ msg, type }) {
if (!msg) return null;
const c = type === 'error' ? '#ff6b9d' : '#4ecdc4';
return (
{/* Scroll container โ overflow hidden so nothing peeks */}
{Array.from({length: pageCount}, (_,pi) => (
{links.slice(pi*pageSize, (pi+1)*pageSize).map(l => )}
))}
{/* Page dots */}
{pageCount > 1 && (
{Array.from({length:pageCount}, (_,i) => (
scrollTo(i)} style={{
width: i===page ? 14 : 5, height:5, borderRadius:3, cursor:'pointer',
background: i===page ? '#4ecdc4' : 'rgba(255,255,255,0.2)',
transition:'all 0.2s',
}}/>
))}
)}
);
}
function QuickLinkIcon({ l }) {
return (
{l.icon && l.icon.startsWith('http')
? e.target.style.display='none'}/>
: {l.icon} }
{l.title}
);
}
function QuickLinks({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [links, setLinks] = useState([]);
const [editMode, setEdit] = useState(false);
const [showForm, setForm] = useState(false);
const [form, setF] = useState({ title:'', url:'', icon:'๐' });
const [editId, setEditId] = useState(null);
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
const getFavicon = url => {
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
catch { return null; }
};
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
let url = form.url.trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
// Auto favicon if user left default
const icon = form.icon === '๐' ? (getFavicon(url) || '๐') : form.icon;
try {
if (editId) {
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, icon, url} });
setLinks(p => p.map(l => l.id===editId ? u : l));
} else {
const n = await api('/dashboard/links', { body:{...form, icon, url} });
setLinks(p => [...p, n]);
}
toast(editId ? 'Aktualisiert' : 'Gespeichert');
setF({ title:'', url:'', icon:'๐' }); setForm(false); setEditId(null);
} catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
if (!await confirm('Link wirklich lรถschen?')) return;
try { await api(`/dashboard/links/${id}`, { method:'DELETE' }); setLinks(p => p.filter(l => l.id!==id)); toast('Gelรถscht'); }
catch(e) { toast(e.message, 'error'); }
};
return (
{/* Link-Grid */}
{links.length === 0 && !editMode
?
Links kรถnnen unter Einstellungen โ Dashboard hinzugefรผgt werden.
: mobile
?
:
}
{/* Formular */}
{showForm && (
{/* Icon-Picker */}
ICON
{CONST_ICONS.map(ic => (
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}
))}
{editId ? 'โ Aktualisieren' : 'โ Speichern'}
{ setForm(false); setEditId(null); setF({ title:'', url:'', icon:'๐' }); }} style={{ ...S.btn('#ff6b9d'), flex:1, textAlign:'center' }}>Abbrechen
)}
);
}
function TodoList({ toast }) {
const [items, setItems] = useState([]);
const [text, setText] = useState('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => { api('/dashboard/todos').then(setItems).catch(() => {}); }, []);
const add = async () => {
if (!text.trim()) return;
try { const n = await api('/dashboard/todos', { body:{ text:text.trim() } }); setItems(p => [...p, n]); setText(''); }
catch(e) { toast(e.message, 'error'); }
};
const toggle = async item => {
try { const u = await api(`/dashboard/todos/${item.id}`, { method:'PUT', body:{ done:!item.done } }); setItems(p => p.map(i => i.id===item.id ? u : i)); }
catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
try { await api(`/dashboard/todos/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id!==id)); }
catch(e) { toast(e.message, 'error'); }
};
const open = items.filter(i => !i.done);
const done = items.filter(i => i.done);
return (
setIsOpen(v=>!v)} style={{width:'100%',background:'transparent',border:'none',
display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer',padding:0}}>
TO-DO{items.filter(i=>!i.done).length > 0
? ({items.filter(i=>!i.done).length})
: null}
โถ
{isOpen && <>
setText(e.target.value)}
onKeyDown={e => e.key==='Enter' && add()}
style={{ ...S.inp, flex:1, fontSize:16 }} placeholder="Neue Aufgabeโฆ" />
+
{open.length === 0 && done.length === 0 && (
Noch keine Aufgaben.
)}
{open.map(item => (
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',
}} />
{item.text}
del(item.id)} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)', cursor:'pointer', fontSize:18, padding:'0 2px', lineHeight:1 }}>โ
))}
{done.length > 0 && (
<>
ERLEDIGT
{done.map(item => (
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,
}}>โ
{item.text}
del(item.id)} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)', cursor:'pointer', fontSize:18 }}>โ
))}
>
)}
>}
);
}
function Notepad({ toast }) {
const [content, setContent] = useState('');
const [saved, setSaved] = useState(true);
const [saveTime, setSaveTime] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const tmr = useRef(null);
useEffect(() => {
api('/dashboard/note').then(d => { setContent(d.content || ''); setSaveTime(d.updated_at); }).catch(() => {});
}, []);
const change = val => {
setContent(val); setSaved(false); clearTimeout(tmr.current);
tmr.current = setTimeout(async () => {
try { const r = await api('/dashboard/note', { method:'PUT', body:{ content:val } }); setSaved(true); setSaveTime(r.updated_at); }
catch(e) { toast(e.message, 'error'); }
}, 900);
};
return (
setIsOpen(v=>!v)} style={{width:'100%',background:'transparent',border:'none',
display:'flex',alignItems:'center',gap:8,cursor:'pointer',padding:0}}>
NOTIZEN
{content.length > 0 && (
({content.length} Zeichen)
)}
โถ
{isOpen &&
{saved ? (saveTime ? `โ ${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : 'โ') : 'โฆ'}
}
{isOpen &&
);
}
// โโ Bestell-Statistik Widget โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function BestellStats() {
const [stats, setStats] = useState(null);
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = () => {
if (loaded) return;
api('/dashboard/stats').then(s => { setStats(s); setLoaded(true); }).catch(() => {});
};
const toggle = () => { setOpen(v => !v); if (!open) load(); };
const Row = ({label, val, color='#fff'}) => (
{label}
{val}
);
return (
3D-DRUCK STATISTIK
โถ
{open && (
{!stats ? (
Lรคdtโฆ
) : (
<>
>
)}
)}
);
}
// โโ Nachrichten Dashboard Widget โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function NachrichtenWidget({ unreadMsgs, setActive }) {
const [isOpen, setIsOpen] = useState(false);
return (
setIsOpen(v=>!v)} style={{
width:'100%', background:'transparent', border:'none', cursor:'pointer',
display:'flex', alignItems:'center', padding:0, gap:8 }}>
NACHRICHTEN
{unreadMsgs > 0 && !isOpen && (
{unreadMsgs} neu
)}
โถ
{isOpen && (
0 ? '#4ecdc4' : 'rgba(255,255,255,0.35)',
fontSize:12, fontFamily:'monospace' }}>
{unreadMsgs > 0
? `${unreadMsgs} ungelesene Nachricht${unreadMsgs !== 1 ? 'en' : ''}`
: 'Keine neuen Nachrichten'}
setActive('nachrichten')} style={{ ...S.btn('#4ecdc4', true), flexShrink:0 }}>
รffnen โ
)}
);
}
// โโ Pushover Settings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function PushoverSettings({ toast }) {
const [form, setForm] = useState({ user_key:'', app_token:'', retry: '', expire: '' });
const [busy, setBusy] = useState(false);
const [hasSaved, setHasSaved] = useState(false);
const [emergency,setEmergency]= useState(false);
useEffect(() => {
api('/tools/nachrichten/pushover')
.then(d => {
const v = { user_key: d.user_key||'', app_token: d.app_token||'',
retry: d.retry||'', expire: d.expire||'' };
setForm(v);
setHasSaved(!!(v.user_key && v.app_token));
setEmergency(!!(d.retry && d.expire));
}).catch(()=>{});
}, []);
const save = async () => {
if (!form.user_key.trim() || !form.app_token.trim()) { toast('Beide Felder ausfรผllen', 'error'); return; }
setBusy(true);
try {
await api('/tools/nachrichten/pushover', { body: {
...form,
retry: emergency ? (parseInt(form.retry) || 60) : null,
expire: emergency ? (parseInt(form.expire) || 3600) : null,
}});
toast('Gespeichert'); setHasSaved(true);
} catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const test = async () => {
setBusy(true);
try { await api('/tools/nachrichten/pushover/test', { method:'POST', body:{ emergency: false } }); toast('Test gesendet โ (normale Prioritรคt)'); }
catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const testEmergency = async () => {
setBusy(true);
try { await api('/tools/nachrichten/pushover/test', { method:'POST', body:{ emergency: true } }); toast('๐จ Emergency-Test gesendet โ quittiere in der Pushover-App!'); }
catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const clear = async () => {
try {
await api('/tools/nachrichten/pushover', { method:'DELETE' });
setForm({ user_key:'', app_token:'', retry:'', expire:'' });
setHasSaved(false); setEmergency(false);
toast('Pushover entfernt');
} catch(e) { toast(e.message, 'error'); }
};
return (
Push-Benachrichtigungen via{' '}
pushover.net .
Du brauchst einen Account und eine eigene App (kostenlos).
setForm(p=>({...p,user_key:e.target.value}))} />
setForm(p=>({...p,app_token:e.target.value}))} />
{/* Emergency / Wiederholung */}
{busy ? 'โฆ' : 'โ Speichern'}
{hasSaved && (
Test
)}
{hasSaved && emergency && (
๐จ Test
)}
{hasSaved && (
โ
)}
);
}
// โโ Ideen-Board Modal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// โโ Board ItemRow (auรerhalb BoardModal damit Inputs stabil bleiben) โโโโโโโโโโโ
function BoardItemRow({ item, isAdmin, userId, editId, editForm, setEditId, setEditForm, onSave, onDelete, onPromote }) {
const isEditing = editId === item.id;
const canEdit = isAdmin || (item.user_id === userId && item.type === 'wish');
return (
{isEditing ? (
setEditForm(p=>({...p,title:e.target.value}))}
style={{...S.inp,marginBottom:6,fontSize:13}} autoFocus/>
setEditForm(p=>({...p,description:e.target.value}))}
placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:12}}/>
onSave(item.id)} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>โ
setEditId(null)} style={{...S.btn('#ff6b9d',true),fontSize:11,padding:'5px 10px'}}>โ
) : (
{item.title}
{!!item.promoted_from_wish && (
โ aus Wรผnschen
)}
{item.description &&
{item.description}
}
{item.author} ยท {item.created_at ? new Date(item.created_at.replace(' ','T')).toLocaleDateString('de-DE') : ''}
{isAdmin && item.type==='wish' && (
onPromote(item.id)} title="Zur Roadmap befรถrdern"
style={{...S.btn('#ffe66d',true),padding:'3px 8px',fontSize:11}}>โ
)}
{canEdit && (
<>
{setEditId(item.id);setEditForm({title:item.title,description:item.description||''});}}
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11}}>โ
onDelete(item.id)} style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11}}>โ
>
)}
)}
);
}
function BoardModal({ user, toast, onClose, onRead }) {
const isAdmin = user?.role === 'admin';
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ type:'wish', title:'', description:'' });
const [editId, setEditId] = useState(null);
const [editForm, setEditForm] = useState({ title:'', description:'' });
const isMobile = window.innerWidth < 768;
useEffect(()=>{
api('/dashboard/board').then(d=>{ setItems(d.items||[]); setLoading(false); }).catch(()=>setLoading(false));
api('/dashboard/board/read',{method:'POST'}).then(()=>onRead()).catch(()=>{});
},[]);
const add = async () => {
if (!form.title.trim()) { toast('Titel erforderlich','error'); return; }
try {
const r = await api('/dashboard/board',{body:form});
setItems(p=>[r,...p]); setForm({type:'wish',title:'',description:''});
toast('Hinzugefรผgt โ');
} catch(e){ toast(e.message,'error'); }
};
const save = async id => {
try {
const r = await api(`/dashboard/board/${id}`,{method:'PUT',body:editForm});
setItems(p=>p.map(i=>i.id===id?r:i)); setEditId(null); toast('Gespeichert โ');
} catch(e){ toast(e.message,'error'); }
};
const del = async id => {
try { await api(`/dashboard/board/${id}`,{method:'DELETE'}); setItems(p=>p.filter(i=>i.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const promote = async id => {
try {
const r = await api(`/dashboard/board/${id}/promote`,{method:'POST'});
setItems(p=>p.map(i=>i.id===id?r:i)); toast('Zur Roadmap befรถrdert โ');
} catch(e){ toast(e.message,'error'); }
};
const roadmap = items.filter(i=>i.type==='roadmap');
const wishes = items.filter(i=>i.type==='wish');
const rowProps = { isAdmin, userId:user?.id, editId, editForm, setEditId, setEditForm,
onSave:save, onDelete:del, onPromote:promote };
return (
e.target===e.currentTarget&&onClose()}>
{isMobile&&
}
๐ Ideen & Roadmap
โ
๐บ ROADMAP{isAdmin&& (Admin) }
{loading ?
Lรคdtโฆ
: roadmap.length===0 ?
Noch keine Eintrรคge.
: roadmap.map(i=>
)}
{isAdmin && (
)}
๐ก WรNSCHE & IDEEN
{isAdmin &&
โ befรถrdert Wunsch zur Roadmap
}
{loading ? null : wishes.length===0
?
Noch keine Wรผnsche.
: wishes.map(i=>
)}
);
}
// โโ Push-Zeitplaner Widget โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function PushScheduler({ toast }) {
const [isOpen, setIsOpen] = useState(false);
const [schedules, setSchedules] = useState([]);
const [msg, setMsg] = useState('');
const [dt, setDt] = useState('');
const [editId, setEditId] = useState(null);
const [editMsg, setEditMsg] = useState('');
const [editDt, setEditDt] = useState('');
const [hasPushover,setHasPushover]= useState(null); // null=unbekannt
useEffect(() => {
if (!isOpen) return;
api('/dashboard/push-schedules').then(setSchedules).catch(()=>{});
if (hasPushover === null)
api('/tools/nachrichten/pushover').then(d=>setHasPushover(!!(d.user_key&&d.app_token))).catch(()=>{});
}, [isOpen]);
// Vorauswahl: aktuelle Lokalzeit
const defaultDt = () => {
const d = new Date();
const p = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
};
const fmtDt = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?s:d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); };
const add = async () => {
if (!msg.trim()) { toast('Nachricht eingeben','error'); return; }
if (!dt) { toast('Datum und Uhrzeit wรคhlen','error'); return; }
try {
const r = await api('/dashboard/push-schedules',{body:{message:msg.trim(),scheduled_at:dt}});
setSchedules(p=>[...p,r].sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at)));
setMsg(''); setDt(''); toast('Erinnerung gespeichert โ');
} catch(e){ toast(e.message,'error'); }
};
const del = async id => {
try { await api(`/dashboard/push-schedules/${id}`,{method:'DELETE'}); setSchedules(p=>p.filter(s=>s.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const saveEdit = async () => {
try {
const r = await api(`/dashboard/push-schedules/${editId}`,{method:'PUT',body:{message:editMsg,scheduled_at:editDt}});
setSchedules(p=>p.map(s=>s.id===editId?r:s).sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at)));
setEditId(null); toast('Gespeichert โ');
} catch(e){ toast(e.message,'error'); }
};
return (
setIsOpen(v=>!v)} style={{
width:'100%',background:'transparent',border:'none',cursor:'pointer',
display:'flex',alignItems:'center',padding:0,gap:8}}>
๐
PUSH-ERINNERUNGEN
{schedules.length>0&&!isOpen&&(
{schedules.length} aktiv
)}
โถ
{isOpen && (
{hasPushover===false && (
โ Kein Pushover eingerichtet. Bitte erst unter Einstellungen โ Profil โ Pushover konfigurieren.
)}
{/* Neue Erinnerung */}
{/* Liste */}
{schedules.length===0 ? (
Keine aktiven Erinnerungen.
) : schedules.map(s=>(
{editId===s.id ? (
) : (
{s.message}
๐ {fmtDt(s.scheduled_at)}
{setEditId(s.id);setEditMsg(s.message);setEditDt(s.scheduled_at?.slice(0,16)||'');}}
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11,flexShrink:0}}>โ
del(s.id)}
style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11,flexShrink:0}}>โ
)}
))}
)}
);
}
// โโ Changelog Modal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function ChangelogModal({ user, toast, onClose, onRead }) {
const isAdmin = user?.role === 'admin';
const [entries, setEntries] = useState([]);
const [form, setForm] = useState({ version:'', title:'', body:'' });
const [busy, setBusy] = useState(false);
const [boardItems, setBoardItems] = useState([]);
const [toDelete, setToDelete] = useState(new Set()); // ids to delete on submit
const isMobile = window.innerWidth < 768;
useEffect(()=>{
api('/dashboard/changelog').then(setEntries).catch(()=>{});
api('/dashboard/changelog/read',{method:'POST'}).then(()=>{ if(onRead) onRead(); }).catch(()=>{});
if (user?.role==='admin') {
api('/dashboard/board').then(d=>setBoardItems(d.items||[])).catch(()=>{});
}
},[]);
const toggleDelete = id => setToDelete(p => {
const n = new Set(p);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
const add = async () => {
if (!form.version.trim() || !form.title.trim()) { toast('Stichwort und Titel erforderlich','error'); return; }
setBusy(true);
try {
const r = await api('/dashboard/changelog',{body:{
...form,
build_time: typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : null,
}});
setEntries(p=>[r,...p]);
// Markierte Board-Items lรถschen
if (toDelete.size > 0) {
await Promise.all([...toDelete].map(id => api(`/dashboard/board/${id}`,{method:'DELETE'}).catch(()=>{})));
setBoardItems(p => p.filter(i => !toDelete.has(i.id)));
setToDelete(new Set());
}
setForm({version:'',title:'',body:''});
toast('Eintrag hinzugefรผgt โ');
} catch(e){ toast(e.message,'error'); }
setBusy(false);
};
const del = async id => {
try { await api(`/dashboard/changelog/${id}`,{method:'DELETE'}); setEntries(p=>p.filter(e=>e.id!==id)); }
catch(e){ toast(e.message,'error'); }
};
const fmtDate = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?'':d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}); };
const roadmap = boardItems.filter(i=>i.type==='roadmap');
const wishes = boardItems.filter(i=>i.type==='wish');
return (
e.target===e.currentTarget&&onClose()}>
{/* Header */}
{isMobile&&
}
๐ Changelog
โ
{/* Linke Spalte: Formular + Eintrรคge */}
{/* Admin-Formular */}
{isAdmin && (
)}
{/* Eintrรคge */}
{entries.length===0 ? (
Noch keine Changelog-Eintrรคge.
) : entries.map(e=>(
{e.version}
{e.title}
{e.build_time || fmtDate(e.created_at)}
{isAdmin && (
del(e.id)}
style={{background:'transparent',border:'none',cursor:'pointer',
color:'rgba(255,107,157,0.5)',fontSize:13,padding:'0 2px',lineHeight:1}}>โ
)}
{e.body && (
{e.body}
)}
))}
{/* Rechte Spalte: Board-Items zum Abhaken (nur Admin, nur wenn vorhanden) */}
{isAdmin && boardItems.length>0 && (
TODOS MITERLEDIGEN
Markierte Eintrรคge werden beim Hinzufรผgen des Changelogs gelรถscht.
{roadmap.length>0 && (
<>
๐บ ROADMAP
{roadmap.map(item=>(
toggleDelete(item.id)}
style={{marginTop:2,accentColor:'#4ecdc4',flexShrink:0}}/>
{item.title}
{item.description&&
{item.description}
}
))}
>
)}
{wishes.length>0 && (
0?12:0}}>
๐ก WรNSCHE
{wishes.map(item=>(
toggleDelete(item.id)}
style={{marginTop:2,accentColor:'#ffe66d',flexShrink:0}}/>
{item.title}
{item.description&&
{item.description}
}
))}
)}
)}
);
}
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, unreadChangelog=0, onChangelogRead, user }) {
const [now, setNow] = useState(new Date());
const [showBoard, setShowBoard] = useState(false);
const [showChangelog, setShowChangelog] = useState(false);
useEffect(() => { const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []);
return (
{showBoard &&
setShowBoard(false)} onRead={onBoardRead}/>}
{showChangelog && setShowChangelog(false)} onRead={onChangelogRead}/>}
Dashboard
{unreadChangelog > 0 && user?.role !== 'admin' && (
setShowChangelog(true)} style={{
background:'linear-gradient(135deg,rgba(78,205,196,0.15),rgba(78,205,196,0.05))',
border:'1px solid rgba(78,205,196,0.3)', borderRadius:20,
color:'#4ecdc4', cursor:'pointer', fontFamily:'monospace',
fontSize:10, padding:'3px 10px', letterSpacing:0.5,
animation:'pulse 2s infinite',
}}>โจ Was ist neu?
)}
{now.toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })}
{' ยท '}
{now.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}
window.location.reload()}
title="Seite neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14,
fontFamily:'monospace', marginTop:2, flexShrink:0 }}>โบ
setShowChangelog(true)} style={{
background:'transparent', border:'none', cursor:'pointer',
color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace',
letterSpacing:1, padding:0, textDecoration:'underline dotted',
textUnderlineOffset:3 }}>
{typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__
? `Letzte Versionierung: ${__BUILD_TIME__}` : 'Changelog'}
setShowBoard(true)} style={{
position:'relative', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
color:'rgba(255,255,255,0.45)', cursor:'pointer',
padding:'4px 10px', fontSize:9, fontFamily:'monospace', letterSpacing:1 }}>
๐ ToDo
{unreadBoard > 0 && (
)}
{unreadPromoted > 0 && unreadBoard === 0 && (
)}
{unreadPromoted > 0 && unreadBoard > 0 && (
)}
);
}
// โโ Admin Panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Sec muss auรerhalb von AdminPanel definiert sein damit Inputs den Fokus behalten
const Sec = ({title, children}) => (
);
// โโ Account-Lรถschung โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function DeleteAccountSection({ toast, user }) {
const [pw, setPw] = useState('');
const [busy, setBusy] = useState(false);
const { confirm, ConfirmDialog } = useConfirm();
const doDelete = async () => {
if (!pw) { toast('Passwort eingeben', 'error'); return; }
if (!await confirm(`Account "${user.username}" und alle Daten wirklich lรถschen? Das kann nicht rรผckgรคngig gemacht werden.`)) return;
setBusy(true);
try {
await api('/auth/delete-account', { method:'DELETE', body:{ password:pw } });
localStorage.removeItem('sk_token');
window.location.reload();
} catch(e) { toast(e.message, 'error'); } finally { setBusy(false); }
};
return (
setPw(e.target.value)}/>
{busy ? 'โฆ' : 'โ Account unwiderruflich lรถschen'}
);
}
// โโ QuickLinks Einstellungen โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function QuickLinksSettings({ toast }) {
const [links, setLinks] = useState([]);
const [showForm, setForm] = useState(false);
const [form, setF] = useState({ title:'', url:'', icon:'๐' });
const [editId, setEditId] = useState(null);
const dragRef = useRef(null);
const touchRef = useRef({});
const { confirm, ConfirmDialog } = useConfirm();
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
const getFavicon = url => {
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
catch { return null; }
};
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
let url = form.url.trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
const icon = form.icon === '๐' ? (getFavicon(url) || '๐') : form.icon;
try {
if (editId) {
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, icon, url} });
setLinks(p => p.map(l => l.id===editId ? u : l));
} else {
const n = await api('/dashboard/links', { body:{...form, icon, url} });
setLinks(p => [...p, n]);
}
toast(editId ? 'Aktualisiert' : 'Gespeichert');
setF({ title:'', url:'', icon:'๐' }); setForm(false); setEditId(null);
} catch(e) { toast(e.message, 'error'); }
};
const del = async id => {
if (!await confirm('Link wirklich lรถschen?')) return;
try { await api(`/dashboard/links/${id}`, { method:'DELETE' }); setLinks(p => p.filter(l => l.id!==id)); toast('Gelรถscht'); }
catch(e) { toast(e.message, 'error'); }
};
const reorder = async (newLinks) => {
setLinks(newLinks);
try { await api('/dashboard/links-order', { method:'PUT', body:{ ids: newLinks.map(l=>l.id) } }); }
catch(e) { toast(e.message,'error'); }
};
// Desktop drag handlers
const onDragStart = (e, id) => { dragRef.current = id; e.dataTransfer.effectAllowed = 'move'; };
const onDragOver = (e, id) => {
e.preventDefault();
if (dragRef.current === id) return;
const from = links.findIndex(l => l.id === dragRef.current);
const to = links.findIndex(l => l.id === id);
if (from < 0 || to < 0) return;
const next = [...links]; next.splice(to, 0, next.splice(from, 1)[0]);
setLinks(next);
};
const onDrop = () => reorder(links);
// Touch handlers (long press + drag)
const onTouchStart = (e, id) => {
touchRef.current = { id, startY: e.touches[0].clientY, timer: setTimeout(() => {
touchRef.current.active = true;
}, 300) };
};
const onTouchMove = (e) => {
if (!touchRef.current.active) return;
e.preventDefault();
const y = e.touches[0].clientY;
const els = document.elementsFromPoint(e.touches[0].clientX, y);
const target = els.find(el => el.dataset?.linkId);
if (!target) return;
const targetId = parseInt(target.dataset.linkId);
if (targetId === touchRef.current.id) return;
const from = links.findIndex(l => l.id === touchRef.current.id);
const to = links.findIndex(l => l.id === targetId);
if (from < 0 || to < 0) return;
const next = [...links]; next.splice(to, 0, next.splice(from, 1)[0]);
setLinks(next);
};
const onTouchEnd = () => {
clearTimeout(touchRef.current.timer);
if (touchRef.current.active) reorder(links);
touchRef.current = {};
};
return (
{/* Link-Liste */}
{links.length > 0 && (
{links.map(l => (
onDragStart(e, l.id)}
onDragOver={e => onDragOver(e, l.id)}
onDrop={onDrop}
onTouchStart={e => onTouchStart(e, l.id)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',
cursor:'grab', userSelect:'none' }}>
โ ฟ
{l.icon && l.icon.startsWith('http')
? e.target.style.display='none'}/>
: l.icon}
{ setF({ title:l.title, url:l.url, icon:l.icon }); setEditId(l.id); setForm(true); }}
style={S.btn('#4ecdc4', true)}>โ
del(l.id)} style={S.btn('#ff6b9d', true)}>โ
))}
)}
{/* Formular */}
{showForm ? (
ICON
{['๐','๐','๐','๐','๐ ','๐ง','๐','๐พ','๐ฅ','๐ก','๐','๐','๐ฏ','โก','๐ ','๐ง','๐ฑ','๐ฎ','๐','๐ก'].map(ic => (
setF(f => ({...f, icon:ic}))} style={{
width:34, height:34, borderRadius:7, fontSize:17, cursor:'pointer',
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
background: form.icon===ic ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
}}>{ic}
))}
setF(f => ({...f, title:e.target.value}))} placeholder="Mein Link"/>
setF(f => ({...f, url:e.target.value}))} placeholder="https://โฆ" autoCapitalize="none"/>
{editId ? 'โ Aktualisieren' : 'โ Speichern'}
{ setForm(false); setEditId(null); setF({ title:'', url:'', icon:'๐' }); }}
style={{ ...S.btn('#ff6b9d'), flex:1, textAlign:'center', padding:'10px 0' }}>Abbrechen
) : (
{ setForm(true); setEditId(null); setF({ title:'', url:'', icon:'๐' }); }}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
+ Link hinzufรผgen
)}
);
}
// โโ Benutzerverwaltung โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// โโ Login-Sicherheit Admin โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function SecuritySettingsAdmin({ toast }) {
const [s, setS] = useState({ login_max_attempts:'5', login_lockout_minutes:'30' });
const [locked, setLocked] = useState([]);
const [busy, setBusy] = useState(false);
const load = () => {
api('/admin/security-settings').then(setS).catch(()=>{});
api('/admin/lockouts').then(setLocked).catch(()=>{});
};
useEffect(()=>{ load(); },[]);
const save = async () => {
setBusy(true);
try { await api('/admin/security-settings',{method:'PUT',body:s}); toast('Gespeichert โ'); }
catch(e) { toast(e.message,'error'); }
setBusy(false);
};
const unlock = async uid => {
try { await api(`/admin/lockouts/${uid}`,{method:'DELETE'}); toast('Sperre aufgehoben'); setLocked(p=>p.filter(l=>l.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const fmtDate = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?'':d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); };
return (
{busy?'โฆ':'โ Speichern'}
0?`(${locked.length})`:''}`}>
{locked.length===0 ? (
โ Keine gesperrten Konten
) : locked.map(u => (
๐ {u.username}
{u.failed_attempts} Fehlversuch{u.failed_attempts!==1?'e':''} ยท
Gesperrt bis: {fmtDate(u.locked_until)}
{u.last_failed_at && (
Letzter Versuch: {fmtDate(u.last_failed_at)}
)}
{u.ips?.length > 0 && (
{u.ips.map((ip,i) => (
{ip}
))}
)}
unlock(u.id)}
style={{ ...S.btn('#4ecdc4', true), flexShrink:0, fontSize:11 }}>
๐ Entsperren
))}
);
}
function UserManagement({ toast }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ username:'', password:'', role:'user' });
const [resetId, setResetId] = useState(null);
const [resetPw, setResetPw] = useState('');
const [delId, setDelId] = useState(null);
const { confirm, ConfirmDialog } = useConfirm();
const load = () => {
setLoading(true);
api('/admin/users').then(setUsers).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false));
};
useEffect(()=>{ load(); },[]);
const create = async () => {
if (!form.username.trim()||!form.password) { toast('Alle Felder ausfรผllen','error'); return; }
try {
await api('/admin/users',{body:form});
toast(`Benutzer "${form.username}" angelegt`);
setForm({username:'',password:'',role:'user'}); load();
} catch(e) { toast(e.message,'error'); }
};
const del = async id => {
if (!await confirm('Benutzer und alle seine Daten wirklich lรถschen?')) return;
try { await api(`/admin/users/${id}`,{method:'DELETE'}); toast('Gelรถscht'); load(); }
catch(e) { toast(e.message,'error'); }
};
const resetPassword = async () => {
if (!resetPw||resetPw.length<8) { toast('Mind. 8 Zeichen','error'); return; }
try {
await api(`/admin/users/${resetId}/reset-password`,{method:'PUT',body:{newPassword:resetPw}});
toast('Passwort zurรผckgesetzt'); setResetId(null); setResetPw('');
} catch(e) { toast(e.message,'error'); }
};
return (
{/* Neuen Benutzer anlegen */}
setForm(f=>({...f,username:e.target.value}))} autoCapitalize="none"/>
setForm(f=>({...f,password:e.target.value}))}/>
ROLLE
setForm(f=>({...f,role:e.target.value}))} style={{...S.inp,fontSize:14}}>
Benutzer
Administrator
+ Benutzer anlegen
{/* Passwort-Reset Modal */}
{resetId && (
Passwort zurรผcksetzen
setResetPw(e.target.value)}/>
โ Setzen
{setResetId(null);setResetPw('');}} style={{...S.btn('#ff6b9d'),flex:1,textAlign:'center',padding:'10px 0'}}>Abbrechen
)}
{/* Benutzerliste */}
{loading && Lรคdtโฆ
}
{users.map(u => (
{/* Zeile 1: Name + Badges */}
{u.username}
{u.role}
{!!u.has_pushover && (
๐ Push
)}
{!!u.hidden && (
๐ versteckt
)}
{u.last_active_at && (
โ {(() => {
const d = new Date(u.last_active_at.replace(' ','T'));
const diff = Math.floor((new Date() - d) / 60000);
if (diff < 2) return 'gerade eben';
if (diff < 60) return `vor ${diff} min`;
if (diff < 1440) return `vor ${Math.floor(diff/60)} h`;
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
})()}
)}
{/* Aktions-Buttons */}
{setResetId(u.id);setResetPw('');}}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>๐
{
if (u.role==='admin') return;
try {
const r = await api(`/admin/users/${u.id}/hidden`,{method:'PUT'});
setUsers(p=>p.map(x=>x.id===u.id?{...x,hidden:r.hidden}:x));
toast(r.hidden ? 'Ausgeblendet' : 'Eingeblendet');
} catch(e){ toast(e.message,'error'); }
}}
disabled={u.role==='admin'}
title={u.hidden?'Einblenden':'Ausblenden'}
style={{
fontSize:13, cursor: u.role==='admin'?'not-allowed':'pointer',
padding:'4px 7px', borderRadius:6, border:'none',
background: u.hidden ? 'rgba(255,230,109,0.15)' : 'rgba(255,255,255,0.08)',
color: u.hidden ? '#ffe66d' : 'rgba(255,255,255,0.4)',
opacity: u.role==='admin' ? 0.3 : 1,
}}>๐
del(u.id)} style={{...S.btn('#ff6b9d',true),padding:'4px 7px'}}>
{/* Statistik */}
{[
['โ Archiv', u.archiv],
['๐ฆ Bestellungen', u.bestellung],
['> Codes', u.snippets_c],
['โ Todos', u.todos],
['๐ Links', u.links],
['๐ Dateien', u.files],
['๐
iCals', u.icals],
[`โ ${u.noteLen} Zeichen`, null],
].map(([label, val]) => (
{label}{val !== null ? `: ${val}` : ''}
))}
))}
);
}
// โโ Sicherheit / Key-Management โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function SecuritySettings({ toast }) {
const [kp, setKp] = useState(null);
const [fingerprint, setFp] = useState('');
const [expPw, setExpPw] = useState('');
const [expPw2, setExpPw2] = useState('');
const [showExport, setShowExport] = useState(false);
const [impFile, setImpFile] = useState(null);
const [impPw, setImpPw] = useState('');
const [showImport, setShowImport] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
getOrCreateKeyPair()
.then(async k => {
setKp(k);
setFp(await getFingerprint(k));
})
.catch(() => {});
}, []);
const doExport = async () => {
if (!kp) return;
if (expPw.length < 8) { toast('Passwort mind. 8 Zeichen', 'error'); return; }
if (expPw !== expPw2) { toast('Passwรถrter stimmen nicht รผberein', 'error'); return; }
setBusy(true);
try {
const json = await exportEncrypted(kp, expPw);
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([json], { type:'application/json' })),
download: 'dickendock-key.json',
});
a.click(); URL.revokeObjectURL(a.href);
setExpPw(''); setExpPw2(''); setShowExport(false);
toast('Schlรผssel exportiert โ');
} catch { toast('Export fehlgeschlagen', 'error'); }
setBusy(false);
};
const doImport = async () => {
if (!impFile || !impPw) { toast('Datei und Passwort erforderlich', 'error'); return; }
setBusy(true);
try {
const text = await impFile.text();
const newKp = await importEncrypted(text, impPw);
const jwk = await getPublicKeyJwk(newKp);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
setKp(newKp);
setFp(await getFingerprint(newKp));
setImpFile(null); setImpPw(''); setShowImport(false);
toast('Schlรผssel importiert โ');
} catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const doReset = async () => {
if (!window.confirm(
'Neues Schlรผsselpaar erzeugen?\n\n' +
'Alle bisherigen Nachrichten werden vom Server gelรถscht und kรถnnen nicht mehr gelesen werden. ' +
'Gerรคte mit dem alten Schlรผssel kรถnnen keine neuen Nachrichten mehr lesen.'
)) return;
setBusy(true);
try {
const newKp = await generateNewKeyPair();
const jwk = await getPublicKeyJwk(newKp);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
await api('/tools/nachrichten/my-messages', { method:'DELETE' });
toast('Neues Schlรผsselpaar aktiv โ App wird neu geladenโฆ');
setTimeout(() => window.location.reload(), 1500);
} catch(e) { toast(e.message, 'error'); setBusy(false); }
};
const FpChar = ({ c }) => (
{c}
);
return (
{/* Fingerprint */}
Das ist der Fingerprint deines eigenen Public Keys.
Dein Gesprรคchspartner sieht im Chat-Header deinen Fingerprint โ er soll ihn mit diesem hier vergleichen.
Stimmen beide รผberein, ist die Verbindung sicher.
{fingerprint ? (
{fingerprint.split('').map((c, i) => )}
) : (
Wird geladenโฆ
)}
SHA-256(PublicKey) ยท X25519
{/* Export */}
Exportiere deinen privaten Schlรผssel verschlรผsselt als Datei um ihn auf einem anderen Gerรคt zu nutzen.
{!showExport ? (
setShowExport(true)} disabled={!kp}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
โ Schlรผssel exportieren
) : (
setExpPw(e.target.value)}/>
setExpPw2(e.target.value)}/>
{busy ? 'โฆ' : 'โ Herunterladen'}
{ setShowExport(false); setExpPw(''); setExpPw2(''); }}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen
)}
{/* Import */}
Importiere eine zuvor exportierte Schlรผsseldatei. Der aktuelle Schlรผssel wird ersetzt.
{!showImport ? (
setShowImport(true)}
style={{ ...S.btn('#ffe66d'), width:'100%', textAlign:'center', padding:'10px 0' }}>
โ Schlรผssel importieren
) : (
)}
{/* Reset โ Danger Zone */}
โ Gefahrenzone: Erzeugt ein neues Schlรผsselpaar und lรถscht alle deine Nachrichten vom Server.
Alte Nachrichten sind danach nicht mehr lesbar. Gerรคte mit dem alten Schlรผssel werden ausgesperrt.
{busy ? 'โฆ' : 'โ Neues Schlรผsselpaar erzeugen'}
);
}
// Field auรerhalb definiert โ Tastatur bleibt beim Tippen erhalten
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
{label}
);
function AdminPanel({ toast, mobile, user }) {
const [section, setSection] = useState('profil');
const [pw, setPw] = useState({ current:'', newPw:'', confirm:'' });
const [avatar, setAvatar] = useState(localStorage.getItem('dd_avatar') || null);
const [un, setUn] = useState({ newUsername:'', unPw:'' });
const [file, setFile] = useState(null);
const [busy, setBusy] = useState({ pw:false, un:false, restore:false });
const set = (key, val) => setBusy(b => ({...b, [key]:val}));
const changePw = async () => {
if (pw.newPw !== pw.confirm) { toast('Passwรถrter stimmen nicht รผberein', 'error'); return; }
if (pw.newPw.length < 8) { toast('Mindestens 8 Zeichen', 'error'); return; }
set('pw', true);
try { await api('/auth/change-password', { body:{ currentPassword:pw.current, newPassword:pw.newPw } }); toast('Passwort geรคndert'); setPw({ current:'', newPw:'', confirm:'' }); }
catch(e) { toast(e.message, 'error'); } finally { set('pw', false); }
};
const changeUn = async () => {
if (!un.newUsername.trim()) { toast('Benutzername eingeben', 'error'); return; }
set('un', true);
try {
await api('/auth/change-username', { body:{ newUsername:un.newUsername.trim(), password:un.unPw } });
toast('Benutzername geรคndert โ bitte neu einloggen');
setUn({ newUsername:'', unPw:'' });
setTimeout(() => { localStorage.removeItem('sk_token'); window.location.reload(); }, 1800);
} catch(e) { toast(e.message, 'error'); } finally { set('un', false); }
};
const backup = async () => {
try {
const blob = await api('/admin/backup');
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: `dickendock-backup-${new Date().toISOString().split('T')[0]}.db`
});
a.click(); URL.revokeObjectURL(a.href); toast('Backup heruntergeladen');
} catch(e) { toast(e.message, 'error'); }
};
const restore = async () => {
if (!file) { toast('Datei auswรคhlen', 'error'); return; }
if (!window.confirm('โ ๏ธ Aktuelle Datenbank wird ersetzt. Fortfahren?')) return;
set('restore', true);
try {
const f = new FormData(); f.append('database', file);
await api('/admin/restore', { method:'POST', body:f, isFile:true });
toast('Wiederhergestellt'); setFile(null);
} catch(e) { toast(e.message, 'error'); } finally { set('restore', false); }
};
return (
Einstellungen
window.location.reload()} title="Seite neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}>โบ
{[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])]
.map(([k,l]) => (
setSection(k)} style={{
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
border:`1px solid ${section===k?'rgba(78,205,196,0.5)':'rgba(255,255,255,0.15)'}`,
background: section===k?'rgba(78,205,196,0.12)':'transparent',
color: section===k?'#4ecdc4':'rgba(255,255,255,0.55)',
}}>{l}
))}
{section === 'profil' && (
{avatar
?
:
}
PROFILBILD
{
const file = e.target.files?.[0]; if(!file) return;
const reader = new FileReader();
reader.onload = ev => {
const canvas = document.createElement('canvas');
const img = new Image(); img.onload = () => {
const size = 120;
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.beginPath(); ctx.arc(size/2,size/2,size/2,0,Math.PI*2);
ctx.clip();
const min = Math.min(img.width,img.height);
ctx.drawImage(img,(img.width-min)/2,(img.height-min)/2,min,min,0,0,size,size);
const data = canvas.toDataURL('image/jpeg',0.85);
setAvatar(data); localStorage.setItem('dd_avatar',data); toast('Avatar gespeichert');
}; img.src = ev.target.result;
}; reader.readAsDataURL(file);
}} style={{...S.inp,fontSize:13,padding:'7px 10px',cursor:'pointer'}}/>
{avatar && {setAvatar(null);localStorage.removeItem('dd_avatar');toast('Avatar entfernt');}}
style={{...S.btn('#ff6b9d',true)}}>โ Entfernen }
{user?.role==='admin' &&
setUn(p=>({...p,newUsername:e.target.value}))} />
setUn(p=>({...p,unPw:e.target.value}))} />
{busy.un ? 'โฆ' : 'โ Benutzername รคndern'}
}
setPw(p=>({...p,current:e.target.value}))} />
setPw(p=>({...p,newPw:e.target.value}))} />
setPw(p=>({...p,confirm:e.target.value}))} />
{busy.pw ? 'โฆ' : 'โ Passwort รคndern'}
Lรถscht deinen Account und alle deine Daten unwiderruflich.
)}
{section === 'sicherheit' && (
)}
{section === 'benutzer' && user?.role==='admin' && (
)}
{section === 'dashboard' && (
)}
{section === 'backup' && (
Vollstรคndige SQLite-Datenbank sichern oder wiederherstellen.
โ Backup herunterladen
SICHERUNG EINSPIELEN (.db)
setFile(e.target.files[0])}
style={{ ...S.inp, flex:1, padding:'9px 10px', cursor:'pointer', fontSize:13 }} />
{busy.restore ? 'โฆ' : 'โ'}
{file && {file.name}
}
)}
);
}
// โโ App Shell โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export default function App() {
const mobile = useIsMobile();
const [splash, setSplash] = useState(() => {
// Only show splash in standalone PWA mode, and only once per session
return window.matchMedia('(display-mode: standalone)').matches && !sessionStorage.getItem('splash_shown');
});
const [user,setUser]=useState(null);
const [active,setActiveRaw]=useState('dashboard');
const setActive = (page) => {
import('./main.jsx').then(m => m.reloadIfNewBuild?.()).catch(()=>{});
setActiveRaw(page);
};
const [toastMsg,setToastMsg]=useState({msg:'',type:'success'});
const [updateInfo,setUpdateInfo]=useState(null);
const [showUpd,setShowUpd]=useState(false);
const [unreadMsgs, setUnreadMsgs] = useState(0);
const [unreadBoard, setUnreadBoard] = useState(0);
const [unreadPromoted, setUnreadPromoted] = useState(0);
const [unreadChangelog,setUnreadChangelog]= useState(0);
const [authChecked, setAuthChecked] = useState(false);
useEffect(()=>{
const token=localStorage.getItem('sk_token');
if(token){
try{const p=JSON.parse(atob(token.split('.')[1]));
if(p.exp*1000>Date.now())setUser({id:p.id,username:p.username,role:p.role});
else localStorage.removeItem('sk_token');
}catch{localStorage.removeItem('sk_token');}
}
setAuthChecked(true);
},[]);
useEffect(()=>{
if(!user)return;
const check=()=>api('/system/update-check').then(setUpdateInfo).catch(()=>{});
check();
const t=setInterval(check,5*60*1000);
return()=>clearInterval(t);
},[user]);
// Schlรผssel sofort nach Login initialisieren und Public Key registrieren
useEffect(()=>{
if(!user)return;
getOrCreateKeyPair().then(async kp=>{
try{
const jwk=await getPublicKeyJwk(kp);
await api('/tools/nachrichten/keys',{body:{public_key:JSON.stringify(jwk)}});
}catch{}
}).catch(()=>{});
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/dashboard/board/unread').then(d=>{ setUnreadBoard(d.unread||0); setUnreadPromoted(d.unreadPromoted||0); }).catch(()=>{});
poll();
const t=setInterval(poll,30*1000);
return()=>clearInterval(t);
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/dashboard/changelog/unread').then(d=>setUnreadChangelog(d.unread||0)).catch(()=>{});
poll();
const t=setInterval(poll,60*1000);
return()=>clearInterval(t);
},[user]);
useEffect(()=>{
if(!user)return;
const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(d.count||0)).catch(()=>{});
poll();
const t=setInterval(poll,30*1000);
return()=>clearInterval(t);
},[user]);
const toast=useCallback((msg,type='success')=>{
setToastMsg({msg,type});
setTimeout(()=>setToastMsg({msg:'',type:'success'}),3500);
},[]);
if(splash) return
{ setSplash(false); sessionStorage.setItem('splash_shown','1'); }}/>;
if(!authChecked) return ;
if(!user) return
setUser(u)}/>;
const activeTool = TOOLS.find(t=>t.id===active);
// Mobile padding bottom fรผr fixed nav
const isNachrichten = active === 'nachrichten';
const mainStyle = {
flex:1,
overflowY: 'auto',
...(mobile ? { paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' } : {}),
};
return(
<>
{!mobile && (
{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}
unreadMsgs={unreadMsgs}/>
)}
{active==='dashboard' && { setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} user={user}/>}
{active==='admin' && }
{activeTool && }
{mobile && (
{localStorage.removeItem('sk_token');setUser(null);}}
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}
unreadMsgs={unreadMsgs}/>
)}
{showUpd&&updateInfo&&setShowUpd(false)}/>}
>
);
}