diff --git a/frontend/App.jsx b/frontend/App.jsx
new file mode 100644
index 0000000..98b6a9b
--- /dev/null
+++ b/frontend/App.jsx
@@ -0,0 +1,919 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { api, S } from './lib.js';
+import { TOOLS, getGroupedTools } from './toolRegistry.js';
+import { useConfirm } from './confirm.jsx';
+import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon } from './icons.jsx';
+
+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 (
+
+ {type==='error'?'โ ':'โ '}{msg}
+
+ );
+}
+
+// โโ Modal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+function Modal({ title, onClose, children }) {
+ return (
+ e.target===e.currentTarget&&onClose()}>
+
+
+
+ {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 (
+
+ );
+}
+
+// โโ 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 (
+
+
+
+ {[['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 }) {
+ 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 }) {
+ 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}) => !open ? null : (
+
+
e.stopPropagation()}>
+
+ {children}
+
+
+
+ );
+
+ const mainNav = [
+ { id:'dashboard', Icon:HomeIcon, label:'Home' },
+ { id:'_apps', Icon:AppsIcon, label:'Apps' },
+ ...(user?.role==='admin' ? [{ id:'admin', Icon:AdminIcon, label:'Einstellungen' }] : []),
+ { 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+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
+
+ {editMode && }
+
+
+
+
+ {/* Link-Grid */}
+ {links.length === 0 && !editMode
+ ?
Tippe auf "โ" um Links hinzuzufรผgen.
+ :
+ {links.map(l => (
+
+ {editMode && (
+
+
+
+
+ )}
+
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' }}>
+ {l.icon && l.icon.startsWith('http')
+ ?
e.target.style.display='none'}/>
+ : {l.icon}}
+ {l.title}
+
+
+ ))}
+
+ }
+
+ {/* Formular */}
+ {showForm && (
+
+ {/* Icon-Picker */}
+
+
ICON
+
+ {ICONS.map(ic => (
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+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 (
+
+
TO-DO
+
+ 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 => (
+
+ 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 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 (
+
+
+
NOTIZEN
+
+ {saved ? (saveTime ? `โ ${new Date(saveTime).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}` : 'โ') : 'โฆ'}
+
+
+
+ );
+}
+
+
+// โโ 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โฆ
+ ) : (
+ <>
+
+
BESTELLUNGEN
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
+
+function Dashboard({ toast, mobile }) {
+ return (
+
+
+
Dashboard
+
+ {new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })}
+
+
+
+
+
+
+
+ );
+}
+
+
+// โโ Admin Panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// Sec muss auรerhalb von AdminPanel definiert sein damit Inputs den Fokus behalten
+const Sec = ({title, children}) => (
+
+);
+
+// Field auรerhalb definiert โ Tastatur bleibt beim Tippen erhalten
+const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
+
+
+
+
+);
+
+function AdminPanel({ toast, mobile }) {
+ 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
+
+
+ {[['profil','Profil'],['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
+ ?

+ :
}
+
+
+
+ {
+ 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}
+
+
+ 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'}
+
+
+
+ )}
+
+ {section === 'backup' && (
+
+
+
+ Vollstรคndige SQLite-Datenbank sichern oder wiederherstellen.
+
+
+ โ Backup herunterladen
+
+
+
+ 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(() => window.matchMedia('(display-mode: standalone)').matches);
+ 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(splash) return setSplash(false)}/>;
+ if(!user) return 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(
+ <>
+
+
+ {!mobile && (
+
{localStorage.removeItem('sk_token');setUser(null);}}
+ updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}/>
+ )}
+
+ {active==='dashboard' && }
+ {active==='admin' && user?.role==='admin' && }
+ {activeTool && }
+
+
+ {mobile && (
+ {localStorage.removeItem('sk_token');setUser(null);}}
+ updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}/>
+ )}
+
+ {showUpd&&updateInfo&&setShowUpd(false)}/>}
+ >
+ );
+}