From afae09dd6eba5433ec607d6f357be76b0fae0819 Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 28 May 2026 18:26:23 +0200 Subject: [PATCH] =?UTF-8?q?ToDo-Board:=20BoardModal,=20Roadmap,=20W=C3=BCn?= =?UTF-8?q?sche,=20Unread-Polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 22 + backend/src/routes/dashboard.js | 69 +- frontend/src/App.jsx | 186 +++- frontend/src/tools/App.jsx | 1607 ------------------------------- 4 files changed, 269 insertions(+), 1615 deletions(-) delete mode 100644 frontend/src/tools/App.jsx diff --git a/backend/src/db.js b/backend/src/db.js index 3538d52..f7c774a 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -284,6 +284,28 @@ for (const [k, v] of loginDefaults) { db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v); } +// ── Ideen-Board ─────────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) { + db.exec(` + CREATE TABLE board_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('roadmap','wish')), + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT NULL + ) + `); +} +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) { + db.exec(` + CREATE TABLE board_reads ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + last_read DATETIME DEFAULT NULL + ) + `); +} + // ── Link-Liste ──────────────────────────────────────────────────────────────── if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) { db.exec(` diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index e5b2083..3b18312 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -67,7 +67,6 @@ router.put('/note', authenticate, (req, res) => { res.json({ success: true, updated_at: new Date().toISOString() }); }); -module.exports = router; // ── Statistiken ─────────────────────────────────────────────────────────────── router.get('/stats', authenticate, (req, res) => { @@ -123,3 +122,71 @@ router.get('/stats', authenticate, (req, res) => { res.json({ totalOrders, bezahltOrders, totalRevenue, offeneRevenue, ordersThisMonth, totalCalcs, byStatus }); }); + +// ── Ideen-Board ─────────────────────────────────────────────────────────────── +router.get('/board', authenticate, (req, res) => { + const u = uid(req); + const items = db.prepare(` + SELECT b.*, us.username as author + FROM board_items b JOIN users us ON us.id=b.user_id + ORDER BY b.type ASC, b.created_at DESC + `).all(); + + const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read; + const unread = lastRead + ? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c + : db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c; + + res.json({ items, unread }); +}); + +router.get('/board/unread', authenticate, (req, res) => { + const u = uid(req); + const lastRead = db.prepare('SELECT last_read FROM board_reads WHERE user_id=?').get(u)?.last_read; + const unread = lastRead + ? db.prepare("SELECT COUNT(*) c FROM board_items WHERE created_at > ? AND user_id != ?").get(lastRead, u).c + : db.prepare('SELECT COUNT(*) c FROM board_items WHERE user_id != ?').get(u).c; + res.json({ unread }); +}); + +router.post('/board/read', authenticate, (req, res) => { + const u = uid(req); + db.prepare(`INSERT OR REPLACE INTO board_reads (user_id, last_read) VALUES (?, datetime('now','localtime'))`).run(u); + res.json({ ok: true }); +}); + +router.post('/board', authenticate, (req, res) => { + const u = uid(req); + const { type, title, description = '' } = req.body; + if (!['roadmap','wish'].includes(type)) return res.status(400).json({ error: 'Ungültiger Typ' }); + if (type === 'roadmap' && req.user.role !== 'admin') return res.status(403).json({ error: 'Nur Admins' }); + if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' }); + const r = db.prepare(`INSERT INTO board_items (type, user_id, title, description, created_at) VALUES (?,?,?,?,datetime('now','localtime'))`) + .run(type, u, title.trim(), description.trim()); + res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(r.lastInsertRowid), + author: req.user.username }); +}); + +router.put('/board/:id', authenticate, (req, res) => { + const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Nicht gefunden' }); + const isAdmin = req.user.role === 'admin'; + const isOwn = item.user_id === uid(req); + if (!isAdmin && (!isOwn || item.type === 'roadmap')) return res.status(403).json({ error: 'Kein Zugriff' }); + const { title, description } = req.body; + db.prepare('UPDATE board_items SET title=?, description=? WHERE id=?') + .run(title ?? item.title, description ?? item.description, item.id); + res.json({ ...db.prepare('SELECT * FROM board_items WHERE id=?').get(item.id), author: req.user.username }); +}); + +router.delete('/board/:id', authenticate, (req, res) => { + const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Nicht gefunden' }); + const isAdmin = req.user.role === 'admin'; + const isOwn = item.user_id === uid(req); + if (!isAdmin && !isOwn) return res.status(403).json({ error: 'Kein Zugriff' }); + db.prepare('DELETE FROM board_items WHERE id=?').run(item.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1ea906c..104ab74 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -821,11 +821,162 @@ function PushoverSettings({ toast }) { ); } -function Dashboard({ toast, mobile, setActive, unreadMsgs=0 }) { +// ── Ideen-Board Modal ───────────────────────────────────────────────────────── +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({}); + 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 roadmap = items.filter(i=>i.type==='roadmap'); + const wishes = items.filter(i=>i.type==='wish'); + + const ItemRow = ({item}) => { + const canEdit = isAdmin || item.user_id === user?.id; + const isEditing = editId === item.id; + return ( +
+ {isEditing ? ( +
+ setEditForm(p=>({...p,title:e.target.value}))} + style={{...S.inp,marginBottom:6,fontSize:13}}/> + setEditForm(p=>({...p,description:e.target.value}))} + placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:12}}/> +
+ + +
+
+ ) : ( +
+
+
{item.title}
+ {item.description &&
{item.description}
} +
+ {item.author} · {item.created_at ? new Date(item.created_at.replace(' ','T')).toLocaleDateString('de-DE') : ''} +
+
+ {canEdit && ( +
+ + +
+ )} +
+ )} +
+ ); + }; + + return ( +
e.target===e.currentTarget&&onClose()}> +
+ + {/* Header */} +
+ {isMobile&&
} +
📋 Ideen & Roadmap
+ +
+ +
+ {/* Roadmap */} +
+
🗺 ROADMAP{isAdmin&& (Admin)}
+ {loading ?
Lädt…
+ : roadmap.length===0 ?
Noch keine Einträge.
+ : roadmap.map(i=>)} + {isAdmin && ( +
+ setForm(f=>({...f,type:'roadmap'}))} + onChange={e=>setForm(f=>({...f,title:e.target.value,type:'roadmap'}))} + onKeyDown={e=>e.key==='Enter'&&form.type==='roadmap'&&add()} + style={{...S.inp,fontSize:13,marginBottom:form.type==='roadmap'&&form.title?6:0}}/> + {form.type==='roadmap'&&form.title&&( +
+ setForm(f=>({...f,description:e.target.value}))} + style={{...S.inp,fontSize:12,flex:1}}/> + +
+ )} +
+ )} +
+ + {/* Wishes */} +
+
💡 WÜNSCHE & IDEEN
+ {loading ? null : wishes.length===0 + ?
Noch keine Wünsche.
+ : wishes.map(i=>)} +
+ setForm(f=>({...f,type:'wish'}))} + onChange={e=>setForm(f=>({...f,title:e.target.value,type:'wish'}))} + onKeyDown={e=>e.key==='Enter'&&form.type==='wish'&&add()} + style={{...S.inp,fontSize:13,marginBottom:form.type==='wish'&&form.title?6:0}}/> + {form.type==='wish'&&form.title&&( +
+ setForm(f=>({...f,description:e.target.value}))} + style={{...S.inp,fontSize:12,flex:1}}/> + +
+ )} +
+
+
+
+
+ ); +} + +function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, user }) { const [now, setNow] = useState(new Date()); + const [showBoard, setShowBoard] = useState(false); + const [boardUnread, setBoardUnread] = useState(unreadBoard); + useEffect(()=>{ setBoardUnread(unreadBoard); },[unreadBoard]); useEffect(() => { const t = setInterval(() => setNow(new Date()), 30000); return () => clearInterval(t); }, []); return (
+ {showBoard && setShowBoard(false)} onRead={()=>setBoardUnread(0)}/>}

Dashboard

@@ -846,10 +997,22 @@ function Dashboard({ toast, mobile, setActive, unreadMsgs=0 }) { -
- {typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__ - ? `Letzte Versionierung: ${__BUILD_TIME__}` : null} +
+
+ {typeof __BUILD_TIME__ !== 'undefined' && __BUILD_TIME__ + ? `Letzte Versionierung: ${__BUILD_TIME__}` : null} +
+
); @@ -1607,7 +1770,8 @@ export default function App() { const [toastMsg,setToastMsg]=useState({msg:'',type:'success'}); const [updateInfo,setUpdateInfo]=useState(null); const [showUpd,setShowUpd]=useState(false); - const [unreadMsgs,setUnreadMsgs]=useState(0); + const [unreadMsgs, setUnreadMsgs] = useState(0); + const [unreadBoard, setUnreadBoard] = useState(0); const [authChecked, setAuthChecked] = useState(false); useEffect(()=>{ @@ -1640,6 +1804,14 @@ export default function App() { }).catch(()=>{}); },[user]); + useEffect(()=>{ + if(!user)return; + const poll=()=>api('/dashboard/board/unread').then(d=>setUnreadBoard(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(()=>{}); @@ -1687,7 +1859,7 @@ export default function App() { unreadMsgs={unreadMsgs}/> )}
- {active==='dashboard' && } + {active==='dashboard' && } {active==='admin' && } {activeTool && }
diff --git a/frontend/src/tools/App.jsx b/frontend/src/tools/App.jsx deleted file mode 100644 index cbb67a1..0000000 --- a/frontend/src/tools/App.jsx +++ /dev/null @@ -1,1607 +0,0 @@ -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 &&