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 (
{type==='error'?'โœ• ':'โœ“ '}{msg}
); } // โ”€โ”€ Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function Modal({ title, onClose, children }) { return (
=768 ? 'center' : 'flex-end', justifyContent:'center', padding: window.innerWidth>=768 ? 24 : 0 }} onClick={e=>e.target===e.currentTarget&&onClose()}>
=768 ? 16 : '16px 16px 0 0', padding:'20px 20px 40px', width:'100%', maxWidth:600, maxHeight:'85vh', overflowY:'auto', boxShadow: window.innerWidth>=768 ? '0 24px 80px rgba(0,0,0,0.6)' : 'none' }}> {window.innerWidth < 768 &&
}
{title}
{children}
); } // โ”€โ”€ PWA Splash Screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function SplashScreen({ onDone }) { const [fade, setFade] = useState(false); useEffect(() => { const t1 = setTimeout(() => setFade(true), 1200); const t2 = setTimeout(() => onDone(), 1700); return () => { clearTimeout(t1); clearTimeout(t2); }; }, []); return (
DICKENDOCK
โš’
); } // โ”€โ”€ 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); initNachrichtenKeys(); onLogin(d.user); } catch(e) { setErr(e.message); } finally { setBusy(false); } }; return (
โš’
DICKENDOCK
{[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>(
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" />
))} {err &&
โœ• {err}
}
); } // โ”€โ”€ Update Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function UpdateModal({ data, onClose }) { return (
Installiert: {data.currentVersion} {' โ†’ '}{data.latestVersion}
{(data.releases||[]).map((r,i)=>(
{r.name||r.version} {r.publishedAt?new Date(r.publishedAt).toLocaleDateString('de-DE'):''}
{r.body}
))}
Update: docker compose up -d --build
); } // โ”€โ”€ Desktop Sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function Sidebar({ active, setActive, user, onLogout, updateInfo, onUpdateClick, unreadMsgs=0 }) { const [appsOpen, setAppsOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); const NavBtn = ({ item, sub=false }) => { const ic = active===item.id ? '#4ecdc4' : 'rgba(255,255,255,0.55)'; return ( ); }; return ( ); } // โ”€โ”€ Mobile Bottom Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function BottomNav({ active, setActive, user, onLogout, updateInfo, onUpdateClick, unreadMsgs=0 }) { const [menuOpen, setMenuOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false); const NAV_H = 56; const MI = ({Icon, icon, label, onClick, color='rgba(255,255,255,0.85)'}) => { const Ic = Icon; return ( ); }; const Sheet = ({open, onClose, children}) => { if (!open) return null; const isDesktop = window.innerWidth >= 768; if (isDesktop) return (
e.stopPropagation()}> {children}
); return (
e.stopPropagation()}>
{children}
); }; const mainNav = [ { id:'dashboard', Icon:HomeIcon, label:'Home' }, { id:'_apps', Icon:AppsIcon, label:'Apps' }, { id:'admin', Icon:AdminIcon, label:'Einst.' }, { id:'_mehr', Icon:MoreIcon, label:'Mehr' }, ]; return ( <> {/* Apps Sheet mit Gruppenรผberschriften */} setAppsOpen(false)}>
{getGroupedTools().map(([group, tools]) => (
{group.toUpperCase()}
{tools.map(t => ( { setActive(t.id); setAppsOpen(false); }} /> ))}
))}
{/* Mehr Sheet */} setMenuOpen(false)}> {updateInfo?.hasUpdate && {setMenuOpen(false);onUpdateClick();}} color="#ffe66d"/>}
{}} color="rgba(255,255,255,0.35)"/> {onLogout();setMenuOpen(false);}} color="#ff6b9d"/> {/* Bottom Bar */} ); } // โ”€โ”€ Dashboard Widgets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // โ”€โ”€ QuickLinks Carousel (Mobile) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function QuickLinksCarousel({ links }) { const perRow = 4; const gap = 6; const pageSize = perRow * 2; const pageCount = Math.ceil(links.length / pageSize); const [page, setPage] = useState(0); const scrollRef = useRef(null); const onScroll = () => { if (!scrollRef.current) return; const p = Math.round(scrollRef.current.scrollLeft / scrollRef.current.offsetWidth); setPage(p); }; const canLeft = page > 0; const canRight = page < pageCount - 1; const scrollTo = (p) => { scrollRef.current?.scrollTo({ left: p * scrollRef.current.offsetWidth, behavior:'smooth' }); }; if (pageCount <= 1) return (
{links.map(l => )}
); return (
{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 (
SCHNELLZUGRIFF
{/* 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, title:e.target.value}))} style={{ ...S.inp, fontSize:16 }} placeholder="Mein Link" />
setF(f => ({...f, url:e.target.value}))} onKeyDown={e => e.key==='Enter' && save()} style={{ ...S.inp, fontSize:16 }} placeholder="https://โ€ฆ" inputMode="url" autoCapitalize="none" />
)}
); } 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 (
{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 => (
))} {done.length > 0 && ( <>
ERLEDIGT
{done.map(item => (
{item.text}
))} )}
}
); } 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 (
{isOpen &&
{saved ? (saveTime ? `โœ“ ${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : 'โœ“') : 'โ€ฆ'}
} {isOpen &&