Dateien nach "frontend/src/tools" hochladen

This commit is contained in:
2026-05-27 15:32:43 +02:00
parent 98972e8824
commit 2997acb57e
2 changed files with 326 additions and 1 deletions

View File

@@ -205,7 +205,7 @@ export default function Dateien({ toast, mobile }) {
{/* ── Tabs + Neu-Ordner (überall gleich) ───────────────────────────── */}
<div style={{display:'flex',gap:6,marginBottom:12,alignItems:'center',flexWrap:'wrap'}}>
{[
['own','Meine Dateien', null],
['own','Dateien', null],
['shared','Geteilt mit mir', sharedCnt||null],
['sharedByMe','Geteilt von mir', byMeCnt||null],
].map(([k,l,cnt])=>(

View File

@@ -0,0 +1,325 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
import { useConfirm } from '../confirm.jsx';
import { getOrCreateKeyPair, exportPublicKey, encryptMessage, decryptMessage } from '../crypto.js';
import { SearchIcon, TrashIcon, ChevronIcon } from '../icons.jsx';
const fmtTime = s => new Date(s).toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
const fmtDay = s => {
const d = new Date(s), now = new Date();
if (d.toDateString()===now.toDateString()) return 'Heute';
const y = new Date(now); y.setDate(y.getDate()-1);
if (d.toDateString()===y.toDateString()) return 'Gestern';
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'});
};
// ── Push-Subscription ─────────────────────────────────────────────────────────
async function subscribePush(toast) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
try {
const reg = await navigator.serviceWorker.ready;
const data = await api('/nachrichten/vapid-key');
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: data.publicKey,
});
await api('/nachrichten/subscribe', { body: sub.toJSON() });
} catch(e) { console.warn('Push subscription failed:', e.message); }
}
// ── Setup: Schlüsselpaar + Server registrieren ────────────────────────────────
async function ensureSetup(toast) {
try {
await getOrCreateKeyPair();
const pub = await exportPublicKey();
await api('/nachrichten/keys', { body: { publicKey: pub } });
await subscribePush();
return true;
} catch(e) { toast('Schlüssel-Setup fehlgeschlagen','error'); return false; }
}
// ── Conversation View ─────────────────────────────────────────────────────────
function Conversation({ partner, myUsername, onBack, toast }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [theirKey, setTheirKey] = useState(null);
const [decrypted, setDecrypted] = useState({});
const bottomRef = useRef(null);
const { confirm, ConfirmDialog } = useConfirm();
const loadMessages = useCallback(async () => {
try {
const [msgs, keyData] = await Promise.all([
api(`/nachrichten/with/${partner}`),
api(`/nachrichten/keys/${partner}`).catch(()=>null),
]);
setMessages(msgs);
if (keyData) setTheirKey(keyData.publicKey);
// Decrypt all messages
const dec = {};
for (const m of msgs) {
try {
const partnerKey = m.sender_name === myUsername ? keyData?.publicKey : keyData?.publicKey;
// Both sides use the same shared secret derived from the key exchange
// But we need to know WHICH key to use for decryption
// For messages sent BY me: I encrypted with their public key → decrypt with my private + their public
// For messages FROM them: they encrypted with my public key → decrypt with my private + their public
// In both cases the shared secret is the same (ECDH is symmetric)
if (keyData) {
dec[m.id] = await decryptMessage(m.encrypted_content, m.iv, keyData.publicKey);
}
} catch { dec[m.id] = '🔒 [nicht entschlüsselbar]'; }
}
setDecrypted(dec);
} catch(e) { toast(e.message,'error'); } finally { setLoading(false); }
}, [partner, myUsername]);
useEffect(() => { loadMessages(); }, [loadMessages]);
useEffect(() => { bottomRef.current?.scrollIntoView({behavior:'smooth'}); }, [messages]);
const send = async () => {
if (!input.trim() || !theirKey) return;
setSending(true);
try {
const { encrypted_content, iv } = await encryptMessage(input.trim(), theirKey);
await api('/nachrichten/send', { body: { recipient_username: partner, encrypted_content, iv } });
setInput('');
await loadMessages();
} catch(e) { toast(e.message,'error'); } finally { setSending(false); }
};
const delMsg = async id => {
if (!await confirm('Nachricht für dich löschen?')) return;
try { await api(`/nachrichten/${id}`,{method:'DELETE'}); setMessages(p=>p.filter(m=>m.id!==id)); }
catch(e) { toast(e.message,'error'); }
};
const delAll = async () => {
if (!await confirm(`Gesamtes Gespräch mit ${partner} löschen? Es verschwindet nur bei dir.`)) return;
try { await api(`/nachrichten/conversation/${partner}`,{method:'DELETE'}); onBack(); }
catch(e) { toast(e.message,'error'); }
};
let lastDay = null;
return (
<div style={{display:'flex',flexDirection:'column',height:'calc(100vh - 56px)',maxWidth:700,margin:'0 auto'}}>
<ConfirmDialog/>
{/* Header */}
<div style={{display:'flex',alignItems:'center',gap:10,padding:'14px 14px 10px',
borderBottom:'1px solid rgba(255,255,255,0.07)',background:'#111114',flexShrink:0}}>
<button onClick={onBack} style={{background:'transparent',border:'none',cursor:'pointer',padding:4}}>
<ChevronIcon size={20} color="rgba(255,255,255,0.5)" dir="left"/>
</button>
<div style={{flex:1}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>{partner}</div>
{!theirKey && <div style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:10}}> Kein Schlüssel kann noch nicht senden</div>}
{theirKey && <div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10}}>🔒 Ende-zu-Ende verschlüsselt</div>}
</div>
<button onClick={delAll} title="Gespräch löschen" style={{background:'transparent',border:'none',cursor:'pointer',padding:4}}>
<TrashIcon size={16} color="rgba(255,107,157,0.6)"/>
</button>
</div>
{/* Messages */}
<div style={{flex:1,overflowY:'auto',padding:'12px 14px',display:'flex',flexDirection:'column',gap:6}}>
{loading && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12,textAlign:'center'}}>Lädt</div>}
{!loading && messages.length===0 && (
<div style={{textAlign:'center',marginTop:60}}>
<div style={{fontSize:36,marginBottom:12}}>🔒</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>Noch keine Nachrichten.<br/>Alle Nachrichten sind Ende-zu-Ende verschlüsselt.</div>
</div>
)}
{messages.map(msg => {
const isMe = msg.sender_name === myUsername;
const day = fmtDay(msg.created_at);
const showDay = day !== lastDay; lastDay = day;
return (
<div key={msg.id}>
{showDay && (
<div style={{textAlign:'center',marginBottom:8,marginTop:4}}>
<span style={{background:'rgba(255,255,255,0.07)',borderRadius:20,padding:'2px 10px',
color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>{day}</span>
</div>
)}
<div style={{display:'flex',justifyContent:isMe?'flex-end':'flex-start',alignItems:'flex-end',gap:6}}>
{!isMe && <div style={{width:6,height:6,borderRadius:'50%',background:'#4ecdc4',flexShrink:0,marginBottom:6}}/>}
<div style={{maxWidth:'75%'}}>
<div style={{
background: isMe ? 'linear-gradient(135deg,rgba(78,205,196,0.25),rgba(69,183,209,0.2))' : 'rgba(255,255,255,0.07)',
border: `1px solid ${isMe?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
borderRadius: isMe ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
padding:'8px 12px',
}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,lineHeight:1.5,wordBreak:'break-word'}}>
{decrypted[msg.id] || '🔒 …'}
</div>
</div>
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:3,justifyContent:isMe?'flex-end':'flex-start'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9}}>{fmtTime(msg.created_at)}</span>
<button onClick={()=>delMsg(msg.id)} style={{background:'transparent',border:'none',
cursor:'pointer',padding:0,opacity:0.4,lineHeight:1}} title="Löschen">
<TrashIcon size={10} color="#ff6b9d"/>
</button>
</div>
</div>
</div>
</div>
);
})}
<div ref={bottomRef}/>
</div>
{/* Input */}
<div style={{padding:'10px 14px',borderTop:'1px solid rgba(255,255,255,0.07)',
background:'#111114',flexShrink:0,display:'flex',gap:8,alignItems:'flex-end'}}>
<textarea value={input} onChange={e=>setInput(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}}
placeholder={theirKey?'Nachricht…':'Schlüssel des Empfängers fehlt'}
disabled={!theirKey||sending}
rows={1}
style={{...S.inp,flex:1,resize:'none',minHeight:44,maxHeight:120,lineHeight:1.6,
fontSize:14,padding:'10px 12px',overflowY:'auto'}}/>
<button onClick={send} disabled={!theirKey||sending||!input.trim()} style={{
background:theirKey&&input.trim()?'linear-gradient(135deg,#4ecdc4,#45b7d1)':'rgba(255,255,255,0.08)',
border:'none',borderRadius:10,padding:'10px 16px',cursor:theirKey&&input.trim()?'pointer':'default',
color:theirKey&&input.trim()?'#0d0d0f':'rgba(255,255,255,0.2)',
fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,height:44,flexShrink:0,
}}>{sending?'…':'↑'}</button>
</div>
</div>
);
}
// ── Hauptansicht: Gesprächsliste ──────────────────────────────────────────────
export default function Nachrichten({ toast, mobile, user }) {
const [convs, setConvs] = useState([]);
const [users, setUsers] = useState([]);
const [openPartner, setOpen] = useState(null);
const [search, setSearch] = useState('');
const [newChat, setNewChat] = useState(false);
const [ready, setReady] = useState(false);
const { confirm, ConfirmDialog } = useConfirm();
useEffect(() => {
ensureSetup(toast).then(ok => {
setReady(ok);
if (ok) {
api('/nachrichten/conversations').then(setConvs).catch(()=>{});
api('/nachrichten/users').then(setUsers).catch(()=>{});
}
});
// Refresh every 10s
const t = setInterval(() => {
api('/nachrichten/conversations').then(setConvs).catch(()=>{});
}, 10000);
return () => clearInterval(t);
}, []);
const startChat = username => {
setNewChat(false); setSearch(''); setOpen(username);
};
if (openPartner) return (
<Conversation partner={openPartner} myUsername={user?.username} onBack={()=>{setOpen(null);api('/nachrichten/conversations').then(setConvs);}} toast={toast}/>
);
const filtered = convs.filter(c => c.partner_name.toLowerCase().includes(search.toLowerCase()));
const newFiltered = users.filter(u => u.username.toLowerCase().includes(search.toLowerCase()) && !convs.find(c=>c.partner_name===u.username));
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:700}}>
<ConfirmDialog/>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
<div>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?18:22,margin:0}}>Nachrichten</h1>
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,marginTop:3}}>🔒 Ende-zu-Ende verschlüsselt</div>
</div>
<button onClick={()=>setNewChat(v=>!v)} style={{...S.btn('#4ecdc4'),display:'flex',alignItems:'center',gap:6}}>
{newChat?'✕ Abbrechen':'+ Neu'}
</button>
</div>
{!ready && (
<div style={{...S.card,padding:20,textAlign:'center',color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
Initialisiere Verschlüsselung
</div>
)}
{/* Suche */}
<div style={{position:'relative',marginBottom:12}}>
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)'}}>
<SearchIcon size={14} color="rgba(255,255,255,0.3)"/>
</span>
<input value={search} onChange={e=>setSearch(e.target.value)}
placeholder="Person suchen…"
style={{...S.inp,paddingLeft:34,fontSize:15}}/>
</div>
{/* Neues Gespräch User-Picker */}
{newChat && (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:0,marginTop:0}}>NEUES GESPRÄCH</div>
{users.filter(u=>u.username.toLowerCase().includes(search.toLowerCase())).map(u=>(
<button key={u.id} onClick={()=>startChat(u.username)} style={{
width:'100%',padding:'10px 0',background:'transparent',border:'none',
borderBottom:'1px solid rgba(255,255,255,0.05)',
display:'flex',alignItems:'center',gap:10,cursor:'pointer',
}}>
<div style={{width:34,height:34,borderRadius:'50%',background:'linear-gradient(135deg,#4ecdc4,#ff6b9d)',
display:'flex',alignItems:'center',justifyContent:'center',fontSize:14,flexShrink:0}}>
{u.username[0].toUpperCase()}
</div>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
</button>
))}
</div>
)}
{/* Gesprächsliste */}
{filtered.length===0&&!newChat&&(
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:32,marginBottom:12}}>💬</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
Noch keine Nachrichten.<br/>Klicke auf "+ Neu" um ein Gespräch zu starten.
</div>
</div>
)}
{filtered.map(conv => (
<div key={conv.partner_id} onClick={()=>startChat(conv.partner_name)}
style={{...S.card,marginBottom:6,padding:'12px 14px',cursor:'pointer',
display:'flex',alignItems:'center',gap:12,
border:conv.unread>0?'1px solid rgba(78,205,196,0.2)':'1px solid rgba(255,255,255,0.07)'}}>
<div style={{width:40,height:40,borderRadius:'50%',
background:`linear-gradient(135deg,#4ecdc4,#ff6b9d)`,
display:'flex',alignItems:'center',justifyContent:'center',
fontSize:16,fontWeight:700,color:'#0d0d0f',flexShrink:0}}>
{conv.partner_name[0].toUpperCase()}
</div>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{conv.partner_name}</span>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{fmtDay(conv.last_at)}
</span>
</div>
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:3}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>🔒 Verschlüsselt</span>
{conv.unread>0&&(
<span style={{background:'#4ecdc4',borderRadius:'50%',width:18,height:18,
display:'flex',alignItems:'center',justifyContent:'center',
color:'#0d0d0f',fontFamily:'monospace',fontSize:10,fontWeight:700,flexShrink:0}}>
{conv.unread}
</span>
)}
</div>
</div>
<ChevronIcon size={14} color="rgba(255,255,255,0.25)" dir="right"/>
</div>
))}
</div>
);
}