diff --git a/deploy-helper.bat b/deploy-helper.bat deleted file mode 100644 index 68d7c5a..0000000 --- a/deploy-helper.bat +++ /dev/null @@ -1,60 +0,0 @@ -@echo off -setlocal EnableDelayedExpansion -chcp 65001 >nul - -set PROJEKT=D:\projekt -set DOWNLOADS=%USERPROFILE%\Downloads -set COUNT=0 - -echo. -echo ╔══════════════════════════════════════╗ -echo ║ DickenDock Deploy-Helper ║ -echo ╚══════════════════════════════════════╝ -echo. - -:: Alle Dateien im Download-Ordner prüfen und im Projekt suchen -for %%F in ("%DOWNLOADS%\*.*") do ( - set "DATEI=%%~nxF" - :: Im Projektordner rekursiv nach dieser Datei suchen - for /r "%PROJEKT%" %%P in ("%%~nxF") do ( - copy /Y "%%F" "%%P" >nul 2>&1 - if !ERRORLEVEL!==0 ( - echo ✓ !DATEI! → %%P - set /A COUNT+=1 - ) - ) -) - -if %COUNT%==0 ( - echo Keine passenden Dateien im Download-Ordner gefunden. - echo. - goto :ende -) - -echo. -echo %COUNT% Datei(en) kopiert. -echo. -set /p MSG=" Commit-Nachricht (Enter = 'Update'): " -if "!MSG!"=="" set MSG=Update - -cd /d %PROJEKT% -git add -A -git commit -m "!MSG!" -git push - -if %ERRORLEVEL%==0 ( - echo. - echo ✓ Gepusht! Proxmox-Konsole: - echo. - echo cd /opt/dickendock - echo docker compose down - echo docker builder prune -f - echo docker compose up -d --build - echo. -) else ( - echo ✕ Push fehlgeschlagen. - echo. -) - -:ende -pause diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8170611..cbb67a1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -822,13 +822,24 @@ function PushoverSettings({ toast }) { } function Dashboard({ toast, mobile, setActive, unreadMsgs=0 }) { + const [now, setNow] = useState(new Date()); + useEffect(() => { const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []); return (
-
-

Dashboard

-

- {new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })} -

+
+
+

Dashboard

+

+ {now.toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' })} + {' · '} + {now.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })} +

+
+
@@ -837,9 +848,9 @@ function Dashboard({ toast, mobile, setActive, unreadMsgs=0 }) {
+ color:'rgba(255,255,255,0.35)', fontSize:9, fontFamily:'monospace', letterSpacing:1 }}> {typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__ - ? `GEBAUT ${__BUILD_TIME__}` : null} + ? `Letzte Versionierung: ${__BUILD_TIME__}` : null}
); @@ -1360,7 +1371,12 @@ function AdminPanel({ toast, mobile, user }) { return (
-

Einstellungen

+
+

Einstellungen

+ +
{[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])] diff --git a/frontend/src/tools/App.jsx b/frontend/src/tools/App.jsx new file mode 100644 index 0000000..cbb67a1 --- /dev/null +++ b/frontend/src/tools/App.jsx @@ -0,0 +1,1607 @@ +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 ───────────────────────────────────────────────────────── +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.
+ : + } + + {/* 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 &&