Dateien nach "frontend/src" hochladen
This commit is contained in:
478
frontend/src/App.jsx
Normal file
478
frontend/src/App.jsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api, S } from './lib.js';
|
||||
import { TOOLS } from './toolRegistry.js';
|
||||
|
||||
const ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡'];
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
function Toast({ msg, type }) {
|
||||
if (!msg) return null;
|
||||
const c = type === 'error' ? '#ff6b9d' : '#4ecdc4';
|
||||
return (
|
||||
<div style={{ position:'fixed', bottom:24, right:24, zIndex:9999, background:'#1a1a1e',
|
||||
border:`1px solid ${c}55`, borderRadius:10, padding:'11px 18px',
|
||||
color:c, fontFamily:'monospace', fontSize:12, animation:'fadeIn 0.2s ease',
|
||||
boxShadow:`0 4px 24px ${c}22` }}>
|
||||
{type==='error'?'✕ ':'✓ '}{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||||
function Modal({ title, onClose, children }) {
|
||||
return (
|
||||
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:5000,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', padding:24 }}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{ background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius:14, padding:28, maxWidth:560, width:'100%', maxHeight:'80vh', overflowY:'auto' }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:20 }}>
|
||||
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:15, fontWeight:700 }}>{title}</span>
|
||||
<button onClick={onClose} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)', fontSize:18, cursor:'pointer' }}>✕</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ minHeight:'100vh', background:'#0d0d0f', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<div style={{ width:360, ...S.card, padding:40 }}>
|
||||
<div style={{ textAlign:'center', marginBottom:28 }}>
|
||||
<div style={{ fontSize:32 }}>⚒</div>
|
||||
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:18, fontWeight:700, marginTop:8 }}>
|
||||
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
|
||||
</div>
|
||||
</div>
|
||||
{[['BENUTZERNAME',u,setU,'text'],['PASSWORT',p,setP,'password']].map(([l,v,s,t])=>(
|
||||
<div key={l} style={{ marginBottom:14 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:3 }}>{l}</label>
|
||||
<input type={t} value={v} onChange={e=>s(e.target.value)} onKeyDown={e=>e.key==='Enter'&&go()} style={S.inp} />
|
||||
</div>
|
||||
))}
|
||||
{err && <div style={{ color:'#ff6b9d', fontSize:12, fontFamily:'monospace', marginBottom:12, textAlign:'center' }}>✕ {err}</div>}
|
||||
<button onClick={go} disabled={busy} style={{
|
||||
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
|
||||
borderRadius:8, padding:'11px 0', color:'#0d0d0f', fontFamily:"'Space Mono',monospace",
|
||||
fontWeight:700, fontSize:13, cursor:busy?'default':'pointer', opacity:busy?0.7:1,
|
||||
}}>{busy?'…':'Einloggen →'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
||||
function Sidebar({ active, setActive, user, onLogout, updateInfo, onUpdateClick }) {
|
||||
const coreNav = [{ id:'dashboard', icon:'⬡', label:'Dashboard' }];
|
||||
const adminNav = user?.role==='admin' ? [{ id:'admin', icon:'⚙', label:'Admin' }] : [];
|
||||
|
||||
const NavBtn = ({ item }) => (
|
||||
<button onClick={()=>setActive(item.id)} style={{
|
||||
width:'100%', display:'flex', alignItems:'center', gap:9, padding:'9px 12px',
|
||||
borderRadius:7, border:'none',
|
||||
background: active===item.id ? 'linear-gradient(90deg,rgba(78,205,196,0.14),rgba(78,205,196,0.02))' : 'transparent',
|
||||
color: active===item.id ? '#4ecdc4' : 'rgba(255,255,255,0.6)',
|
||||
cursor:'pointer', fontSize:12, fontFamily:"'Space Mono',monospace", textAlign:'left', marginBottom:1,
|
||||
borderLeft: active===item.id ? '2px solid #4ecdc4' : '2px solid transparent',
|
||||
}}>
|
||||
<span style={{ fontSize:14 }}>{item.icon}</span>
|
||||
<span style={{ flex:1 }}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside style={{ width:220, minHeight:'100vh', background:'#0d0d0f',
|
||||
borderRight:'1px solid rgba(255,255,255,0.06)', display:'flex', flexDirection:'column', flexShrink:0 }}>
|
||||
<div style={{ padding:'24px 20px 18px', borderBottom:'1px solid rgba(255,255,255,0.06)', marginBottom:10 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
||||
<div style={{ width:30, height:30, background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
|
||||
borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', fontSize:15 }}>⚒</div>
|
||||
<div>
|
||||
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700, letterSpacing:1 }}>
|
||||
DICKEN<span style={{ color:'#4ecdc4' }}>DOCK</span>
|
||||
</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace' }}>v1.0.0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ flex:1, padding:'0 10px' }}>
|
||||
{/* Core */}
|
||||
{coreNav.map(i => <NavBtn key={i.id} item={i} />)}
|
||||
|
||||
{/* Tools aus Registry – automatisch generiert */}
|
||||
<div style={{ ...S.head, padding:'10px 12px 4px', marginBottom:0 }}>TOOLS</div>
|
||||
{TOOLS.map(t => <NavBtn key={t.id} item={t} />)}
|
||||
|
||||
{/* Admin */}
|
||||
{adminNav.length>0 && <>
|
||||
<div style={{ ...S.head, padding:'10px 12px 4px', marginBottom:0 }}>SYSTEM</div>
|
||||
{adminNav.map(i => <NavBtn key={i.id} item={i} />)}
|
||||
</>}
|
||||
</nav>
|
||||
|
||||
{/* Update Badge */}
|
||||
{updateInfo?.hasUpdate && (
|
||||
<button onClick={onUpdateClick} style={{
|
||||
margin:'0 10px 8px', background:'rgba(255,230,109,0.08)',
|
||||
border:'1px solid rgba(255,230,109,0.3)', borderRadius:8,
|
||||
padding:'8px 12px', cursor:'pointer', display:'flex', alignItems:'center', gap:8,
|
||||
}}>
|
||||
<span style={{ width:7, height:7, borderRadius:'50%', background:'#ffe66d', flexShrink:0,
|
||||
boxShadow:'0 0 6px #ffe66d', display:'inline-block' }} />
|
||||
<span style={{ color:'#ffe66d', fontSize:11, fontFamily:'monospace' }}>Update verfügbar</span>
|
||||
<span style={{ color:'rgba(255,230,109,0.5)', fontSize:10, fontFamily:'monospace', marginLeft:'auto' }}>{updateInfo.latestVersion}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* User */}
|
||||
<div style={{ padding:'12px 14px 18px', borderTop:'1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
|
||||
<div style={{ width:26, height:26, borderRadius:'50%', background:'linear-gradient(135deg,#ff6b9d,#4ecdc4)',
|
||||
display:'flex', alignItems:'center', justifyContent:'center', fontSize:11 }}>👤</div>
|
||||
<div>
|
||||
<div style={{ color:'#fff', fontSize:11, fontFamily:'monospace' }}>{user?.username}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace' }}>{user?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onLogout} style={{ ...S.btn('#ff6b9d',true), width:'100%', textAlign:'center' }}>Ausloggen</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Update Modal ──────────────────────────────────────────────────────────────
|
||||
function UpdateModal({ data, onClose }) {
|
||||
return (
|
||||
<Modal title="📦 Verfügbare Updates" onClose={onClose}>
|
||||
<div style={{ marginBottom:14, padding:'8px 12px', background:'rgba(255,230,109,0.06)',
|
||||
border:'1px solid rgba(255,230,109,0.2)', borderRadius:8 }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace' }}>
|
||||
Installiert: <span style={{ color:'#4ecdc4' }}>{data.currentVersion}</span>
|
||||
{' → '}Aktuell: <span style={{ color:'#ffe66d' }}>{data.latestVersion}</span>
|
||||
</span>
|
||||
</div>
|
||||
{(data.releases||[]).map((r,i)=>(
|
||||
<div key={i} style={{ marginBottom:12, padding:'12px 14px',
|
||||
background:i===0?'rgba(78,205,196,0.05)':'rgba(255,255,255,0.02)',
|
||||
border:`1px solid ${i===0?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)'}`, borderRadius:8 }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:6 }}>
|
||||
<span style={{ color:i===0?'#4ecdc4':'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>{r.name||r.version}</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.3)', fontSize:10, fontFamily:'monospace' }}>{r.publishedAt?new Date(r.publishedAt).toLocaleDateString('de-DE'):''}</span>
|
||||
</div>
|
||||
<pre style={{ color:'rgba(255,255,255,0.5)', fontSize:12, fontFamily:'monospace', whiteSpace:'pre-wrap', lineHeight:1.6, margin:0 }}>{r.body}</pre>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:11, fontFamily:'monospace', marginTop:8, lineHeight:1.8 }}>
|
||||
Update: <code style={{ color:'#4ecdc4' }}>sh build.sh && docker compose up -d</code>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dashboard Widgets ─────────────────────────────────────────────────────────
|
||||
function QuickLinks({ toast }) {
|
||||
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 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;
|
||||
try {
|
||||
if (editId) {
|
||||
const u = await api(`/dashboard/links/${editId}`,{method:'PUT',body:{...form,url}});
|
||||
setLinks(p=>p.map(l=>l.id===editId?u:l));
|
||||
} else {
|
||||
const n = await api('/dashboard/links',{body:{...form,url}});
|
||||
setLinks(p=>[...p,n]);
|
||||
}
|
||||
toast(editId?'Link aktualisiert':'Link gespeichert');
|
||||
setF({title:'',url:'',icon:'🔗'}); setForm(false); setEditId(null);
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const del = async id => {
|
||||
try { await api(`/dashboard/links/${id}`,{method:'DELETE'}); setLinks(p=>p.filter(l=>l.id!==id)); toast('Link gelöscht'); }
|
||||
catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...S.card, marginBottom:14 }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:14 }}>
|
||||
<div style={S.head}>SCHNELLZUGRIFF</div>
|
||||
<div style={{ display:'flex', gap:6 }}>
|
||||
{editMode && <button onClick={()=>{setForm(true);setEditId(null);setF({title:'',url:'',icon:'🔗'});}} style={S.btn('#4ecdc4',true)}>+ Link</button>}
|
||||
<button onClick={()=>{setEdit(v=>!v);setForm(false);}} style={S.btn(editMode?'#ff6b9d':'rgba(255,255,255,0.4)',true)}>{editMode?'✓ Fertig':'✎ Bearbeiten'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:10, minHeight:50 }}>
|
||||
{links.length===0&&!editMode&&<div style={{ color:'rgba(255,255,255,0.2)', fontSize:12, fontFamily:'monospace', padding:'8px 0' }}>Klicke "Bearbeiten" um Links hinzuzufügen.</div>}
|
||||
{links.map(l=>(
|
||||
<div key={l.id} style={{ position:'relative' }}>
|
||||
{editMode&&(
|
||||
<div style={{ position:'absolute', top:-6, right:-6, display:'flex', gap:2, zIndex:1 }}>
|
||||
<button onClick={()=>{setF({title:l.title,url:l.url,icon:l.icon});setEditId(l.id);setForm(true);}} style={{ width:18,height:18,borderRadius:'50%',background:'#4ecdc4',border:'none',color:'#0d0d0f',fontSize:9,cursor:'pointer' }}>✎</button>
|
||||
<button onClick={()=>del(l.id)} style={{ width:18,height:18,borderRadius:'50%',background:'#ff6b9d',border:'none',color:'#0d0d0f',fontSize:10,cursor:'pointer' }}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<a href={l.url} target="_blank" rel="noopener noreferrer" onClick={editMode?e=>e.preventDefault():undefined}
|
||||
style={{ display:'flex',flexDirection:'column',alignItems:'center',gap:5,padding:'10px 14px',
|
||||
background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius:10,textDecoration:'none',minWidth:70,cursor:editMode?'default':'pointer' }}>
|
||||
<span style={{ fontSize:22 }}>{l.icon}</span>
|
||||
<span style={{ color:'rgba(255,255,255,0.7)',fontSize:10,fontFamily:'monospace',maxWidth:70,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>{l.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showForm&&(
|
||||
<div style={{ marginTop:14,padding:14,background:'rgba(0,0,0,0.2)',border:'1px solid rgba(255,255,255,0.06)',borderRadius:10 }}>
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<div style={S.head}>ICON WÄHLEN</div>
|
||||
<div style={{ display:'flex',flexWrap:'wrap',gap:4 }}>
|
||||
{ICONS.map(ic=>(
|
||||
<button key={ic} onClick={()=>setF(f=>({...f,icon:ic}))} style={{ width:28,height:28,borderRadius:5,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)',cursor:'pointer',fontSize:14 }}>{ic}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'grid',gridTemplateColumns:'1fr 1fr',gap:10,marginBottom:10 }}>
|
||||
<div>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:3 }}>TITEL</label>
|
||||
<input value={form.title} onChange={e=>setF(f=>({...f,title:e.target.value}))} style={S.inp} placeholder="Mein Link" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:3 }}>URL</label>
|
||||
<input value={form.url} onChange={e=>setF(f=>({...f,url:e.target.value}))} onKeyDown={e=>e.key==='Enter'&&save()} style={S.inp} placeholder="https://example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex',gap:8 }}>
|
||||
<button onClick={save} style={S.btn('#4ecdc4')}>{editId?'✓ Aktualisieren':'✓ Speichern'}</button>
|
||||
<button onClick={()=>{setForm(false);setEditId(null);setF({title:'',url:'',icon:'🔗'});}} style={S.btn('#ff6b9d')}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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), done=items.filter(i=>i.done);
|
||||
return(
|
||||
<div style={{...S.card,flex:1}}>
|
||||
<div style={S.head}>TO-DO LISTE</div>
|
||||
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
||||
<input value={text} onChange={e=>setText(e.target.value)} onKeyDown={e=>e.key==='Enter'&&add()} style={{...S.inp,flex:1}} placeholder="Neue Aufgabe…"/>
|
||||
<button onClick={add} style={S.btn('#4ecdc4')}>+</button>
|
||||
</div>
|
||||
<div style={{maxHeight:220,overflowY:'auto'}}>
|
||||
{open.map(item=>(
|
||||
<div key={item.id} style={{display:'flex',alignItems:'center',gap:8,padding:'6px 0',borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||||
<button onClick={()=>toggle(item)} style={{width:16,height:16,borderRadius:3,border:'1px solid rgba(78,205,196,0.4)',background:'transparent',cursor:'pointer',flexShrink:0}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.75)',fontSize:12,fontFamily:'monospace',flex:1,lineHeight:1.4}}>{item.text}</span>
|
||||
<button onClick={()=>del(item.id)} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:13}}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{done.length>0&&<>
|
||||
<div style={{...S.head,padding:'8px 0 4px',marginBottom:0}}>ERLEDIGT</div>
|
||||
{done.map(item=>(
|
||||
<div key={item.id} style={{display:'flex',alignItems:'center',gap:8,padding:'5px 0',opacity:0.4}}>
|
||||
<button onClick={()=>toggle(item)} style={{width:16,height:16,borderRadius:3,border:'1px solid rgba(78,205,196,0.3)',background:'rgba(78,205,196,0.15)',cursor:'pointer',flexShrink:0,fontSize:10,color:'#4ecdc4'}}>✓</button>
|
||||
<span style={{color:'rgba(255,255,255,0.4)',fontSize:12,fontFamily:'monospace',flex:1,textDecoration:'line-through'}}>{item.text}</span>
|
||||
<button onClick={()=>del(item.id)} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:13}}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
{items.length===0&&<div style={{color:'rgba(255,255,255,0.15)',fontSize:11,fontFamily:'monospace',padding:'8px 0'}}>Noch keine Aufgaben.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<div style={{...S.card,flex:1,display:'flex',flexDirection:'column'}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
|
||||
<div style={S.head}>NOTIZBLOCK</div>
|
||||
<div style={{color:saved?'rgba(78,205,196,0.5)':'rgba(255,230,109,0.5)',fontSize:10,fontFamily:'monospace'}}>
|
||||
{saved?(saveTime?`gespeichert ${new Date(saveTime).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}`:'gespeichert'):'…'}
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={content} onChange={e=>change(e.target.value)}
|
||||
placeholder="Notizen, IPs, Zugangsdaten…"
|
||||
style={{...S.inp,flex:1,minHeight:200,resize:'none',lineHeight:1.7,padding:'10px 12px'}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ toast }) {
|
||||
return(
|
||||
<div style={{padding:'36px 44px',maxWidth:1100}}>
|
||||
<div style={{marginBottom:22}}>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:22,margin:0}}>Dashboard</h1>
|
||||
<p style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11,marginTop:5}}>
|
||||
{new Date().toLocaleDateString('de-DE',{weekday:'long',year:'numeric',month:'long',day:'numeric'})}
|
||||
</p>
|
||||
</div>
|
||||
<QuickLinks toast={toast}/>
|
||||
<div style={{display:'flex',gap:14}}><TodoList toast={toast}/><Notepad toast={toast}/></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Admin Panel ───────────────────────────────────────────────────────────────
|
||||
function AdminPanel({ toast }) {
|
||||
const [pw,setPw]=useState({current:'',newPw:'',confirm:''}); const [busy,setBusy]=useState(false);
|
||||
const [file,setFile]=useState(null); const [restoring,setRestoring]=useState(false);
|
||||
|
||||
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;}
|
||||
setBusy(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{setBusy(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;
|
||||
setRestoring(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{setRestoring(false);}
|
||||
};
|
||||
const Sec=({title,children})=><div style={{...S.card,marginBottom:16}}><div style={S.head}>{title}</div>{children}</div>;
|
||||
return(
|
||||
<div style={{padding:'36px 44px',maxWidth:640}}>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:22,marginBottom:24}}>Admin-Bereich</h1>
|
||||
<Sec title="PASSWORT ÄNDERN">
|
||||
{[['Aktuelles Passwort','current'],['Neues Passwort','newPw'],['Bestätigen','confirm']].map(([l,k])=>(
|
||||
<div key={k} style={{marginBottom:12}}>
|
||||
<label style={{...S.head,display:'block',marginBottom:3}}>{l.toUpperCase()}</label>
|
||||
<input type="password" value={pw[k]} onChange={e=>setPw(p=>({...p,[k]:e.target.value}))} style={S.inp}/>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={changePw} disabled={busy} style={S.btn('#4ecdc4')}>{busy?'…':'✓ Passwort ändern'}</button>
|
||||
</Sec>
|
||||
<Sec title="DATENBANK-BACKUP">
|
||||
<p style={{color:'rgba(255,255,255,0.3)',fontSize:12,fontFamily:'monospace',marginBottom:14,lineHeight:1.6}}>Lädt die vollständige SQLite-Datenbank herunter.</p>
|
||||
<button onClick={backup} style={S.btn('#ffe66d')}>↓ Backup herunterladen</button>
|
||||
</Sec>
|
||||
<Sec title="SICHERUNG EINSPIELEN">
|
||||
<p style={{color:'rgba(255,255,255,0.3)',fontSize:12,fontFamily:'monospace',marginBottom:14,lineHeight:1.6}}>⚠️ Aktuelle Datenbank wird vollständig ersetzt.</p>
|
||||
<div style={{display:'flex',gap:10,alignItems:'flex-end'}}>
|
||||
<div style={{flex:1}}>
|
||||
<label style={{...S.head,display:'block',marginBottom:3}}>BACKUP-DATEI (.db)</label>
|
||||
<input type="file" accept=".db" onChange={e=>setFile(e.target.files[0])} style={{...S.inp,padding:'6px 10px',cursor:'pointer'}}/>
|
||||
</div>
|
||||
<button onClick={restore} disabled={restoring||!file} style={{...S.btn('#ff6b9d'),opacity:restoring||!file?0.45:1}}>{restoring?'…':'↑ Einspielen'}</button>
|
||||
</div>
|
||||
{file&&<div style={{color:'rgba(255,255,255,0.25)',fontSize:10,fontFamily:'monospace',marginTop:6}}>{file.name}</div>}
|
||||
</Sec>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App Shell ─────────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
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(!user) return <Login onLogin={u=>setUser(u)}/>;
|
||||
|
||||
// Aktive Tool-Komponente ermitteln
|
||||
const activeTool = TOOLS.find(t=>t.id===active);
|
||||
|
||||
return(
|
||||
<>
|
||||
<style>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:#111114;}
|
||||
input[type=number]::-webkit-inner-spin-button{opacity:0.25;}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
|
||||
::-webkit-scrollbar{width:3px;}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:2px;}
|
||||
select option,textarea{background:#111114;}
|
||||
`}</style>
|
||||
<div style={{display:'flex',minHeight:'100vh',background:'#111114'}}>
|
||||
<Sidebar active={active} setActive={setActive} user={user}
|
||||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
|
||||
updateInfo={updateInfo} onUpdateClick={()=>setShowUpd(true)}/>
|
||||
<main style={{flex:1,overflowY:'auto'}}>
|
||||
{active==='dashboard' && <Dashboard toast={toast}/>}
|
||||
{active==='admin' && user?.role==='admin' && <AdminPanel toast={toast}/>}
|
||||
{activeTool && <activeTool.component toast={toast}/>}
|
||||
</main>
|
||||
</div>
|
||||
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
|
||||
{showUpd&&updateInfo&&<UpdateModal data={updateInfo} onClose={()=>setShowUpd(false)}/>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
frontend/src/lib.js
Normal file
46
frontend/src/lib.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// ── API Client ────────────────────────────────────────────────────────────────
|
||||
// Einheitlicher Fetch-Wrapper für alle Tools und Core-Komponenten
|
||||
export const api = async (path, { body, method, isFile } = {}) => {
|
||||
const token = localStorage.getItem('sk_token');
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
if (!isFile) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: method || (body ? 'POST' : 'GET'),
|
||||
headers,
|
||||
body: isFile ? body : body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (path.includes('/admin/backup') && res.ok) return res.blob();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler');
|
||||
return data;
|
||||
};
|
||||
|
||||
// ── Design Tokens ─────────────────────────────────────────────────────────────
|
||||
// Einheitliche Styles für alle Tools – in jedem Tool importierbar
|
||||
export const S = {
|
||||
// Eingabefeld
|
||||
inp: {
|
||||
width: '100%', background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)', borderRadius: 6,
|
||||
padding: '8px 10px', color: '#fff', fontSize: 13,
|
||||
fontFamily: "'Space Mono',monospace", outline: 'none', boxSizing: 'border-box',
|
||||
},
|
||||
// Button-Factory: btn('#4ecdc4') oder btn('#ff6b9d', true) für kleine Variante
|
||||
btn: (c = '#4ecdc4', sm = false) => ({
|
||||
background: `${c}18`, border: `1px solid ${c}44`,
|
||||
borderRadius: 6, padding: sm ? '5px 10px' : '8px 14px',
|
||||
color: c, cursor: 'pointer', fontFamily: 'monospace',
|
||||
fontSize: sm ? 11 : 12, whiteSpace: 'nowrap', transition: 'all 0.15s',
|
||||
}),
|
||||
// Karten-Container
|
||||
card: {
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius: 12, padding: 20,
|
||||
},
|
||||
// Abschnitts-Überschrift
|
||||
head: {
|
||||
color: 'rgba(255,255,255,0.3)', fontSize: 10,
|
||||
fontFamily: 'monospace', letterSpacing: 2, marginBottom: 14,
|
||||
},
|
||||
};
|
||||
7
frontend/src/main.jsx
Normal file
7
frontend/src/main.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
26
frontend/src/toolRegistry.js
Normal file
26
frontend/src/toolRegistry.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import Kalkulator3D, { SavedList } from './tools/Kalkulator3D.jsx';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Tool-Registry – hier neue Tools eintragen
|
||||
//
|
||||
// Neues Tool hinzufügen:
|
||||
// 1. Backend: src/tools/<name>/routes.js anlegen → wird automatisch geladen
|
||||
// 2. Frontend: src/tools/<Name>.jsx anlegen
|
||||
// 3. Hier einen Eintrag ergänzen
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
export const TOOLS = [
|
||||
{
|
||||
id: 'kalkulator3d',
|
||||
icon: '◈',
|
||||
label: '3D-Kalkulator',
|
||||
component: Kalkulator3D,
|
||||
},
|
||||
{
|
||||
id: 'kalkulator3d-saved',
|
||||
icon: '◉',
|
||||
label: 'Gespeichert',
|
||||
component: SavedList,
|
||||
},
|
||||
// Nächstes Tool:
|
||||
// { id: 'newtool', icon: '◇', label: 'Mein Tool', component: MeinTool },
|
||||
];
|
||||
Reference in New Issue
Block a user