Files
dickendock/frontend/src/tools/dateien.jsx

1039 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
import { useConfirm } from '../confirm.jsx';
import { UploadIcon, DownloadIcon, TrashIcon, SearchIcon, ChevronIcon } from '../icons.jsx';
const fmtSize = b => b<1024?`${b} B`:b<1024*1024?`${(b/1024).toFixed(1)} KB`:`${(b/(1024*1024)).toFixed(1)} MB`;
const fmtDate = s => { if (!s) return ''; const d = new Date(String(s).replace(' ','T')); return isNaN(d)?'':d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}); };
const extIcon = name => {
const e = name.split('.').pop().toLowerCase();
if (['jpg','jpeg','png','gif','webp'].includes(e)) return '🖼';
if (e==='pdf') return '📄'; if (['zip','rar','7z'].includes(e)) return '📦';
if (['mp4','mov','avi'].includes(e)) return '🎬'; if (['mp3','wav'].includes(e)) return '🎵';
if (['stl','3mf'].includes(e)) return '🖨'; if (['txt','md'].includes(e)) return '📝';
if (['xlsx','csv'].includes(e)) return '📊'; if (['docx','doc'].includes(e)) return '📃';
return '📎';
};
const IconBtn = ({ onClick, color, title, children, stop }) => (
<button onClick={stop ? e=>{e.stopPropagation();onClick(e);} : onClick}
title={title}
style={{ background:`${color}14`, border:`1px solid ${color}35`,
borderRadius:7, padding:'6px 8px', cursor:'pointer',
display:'flex', alignItems:'center', justifyContent:'center', gap:4,
color, fontFamily:'monospace', fontSize:11, flexShrink:0 }}>
{children}
</button>
);
// ── Öffentlicher Link-Share Modal ─────────────────────────────────────────────
function PublicShareModal({ item, itemType, onClose, toast }) {
const [password, setPassword] = useState(() => genPw());
const [expiresH, setExpiresH] = useState(1);
const [label, setLabel] = useState('');
const [shares, setShares] = useState([]);
const [creating, setCreating] = useState(false);
const [newLink, setNewLink] = useState(null);
const [newPw, setNewPw] = useState('');
const isFolder = itemType === 'folder';
const base = '/tools/dateien/public-shares';
function genPw() {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const lower = 'abcdefghjkmnpqrstuvwxyz';
const digits = '23456789';
const special = '!@#$%&*-+?';
const all = upper + lower + digits + special;
// Mindestens je 1 aus jeder Kategorie, Rest zufällig
const pick = s => s[Math.floor(Math.random()*s.length)];
const base = [pick(upper), pick(lower), pick(digits), pick(special)];
const rest = Array.from({length:8}, () => pick(all));
return [...base, ...rest].sort(() => Math.random()-0.5).join('');
}
useEffect(() => {
api(base).then(d => setShares((d.shares||[]).filter(s => isFolder
? s.folder_id === item.id
: s.file_id === item.id
))).catch(()=>{});
}, []);
async function createShare() {
if (!password.trim()) { toast('Passwort ist Pflicht'); return; }
setCreating(true);
try {
const body = {
[isFolder ? 'folder_id' : 'file_id']: item.id,
password: password.trim(),
expires_hours: expiresH,
label: label || undefined,
};
const d = await api(base, { body });
const link = `${window.location.origin}/s/${d.token}`;
setNewLink(link);
setNewPw(password.trim());
setShares(p => [d.share, ...p]);
navigator.clipboard?.writeText(link).catch(()=>{});
toast('Link erstellt & kopiert ✓');
setPassword(genPw());
} catch(e) { toast('Fehler: ' + e.message); }
finally { setCreating(false); }
}
async function deleteShare(id) {
await api(`${base}/${id}`, { method:'DELETE' });
setShares(p => p.filter(s => s.id !== id));
toast('Link gelöscht');
}
function copyLink(token) {
const link = `${window.location.origin}/s/${token}`;
navigator.clipboard?.writeText(link).then(() => toast('Link kopiert ✓')).catch(() => toast(link));
}
function fmtExp(exp) {
if (!exp) return 'Kein Ablauf';
const d = new Date(exp * 1000);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'});
}
const expiryOptions = [
{ label:'1 Stunde', h:1 },
{ label:'24 Stunden', h:24 },
{ label:'7 Tage', h:168 },
{ label:'30 Tage', h:720 },
];
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,display:'flex',alignItems:'center',justifyContent:'center',padding:16}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:14,padding:24,width:'100%',maxWidth:440,maxHeight:'85vh',overflowY:'auto'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:14,fontWeight:700}}>🔗 Öffentlicher Link</div>
<button onClick={onClose} style={{background:'none',border:'none',color:'rgba(255,255,255,0.5)',fontSize:18,cursor:'pointer'}}></button>
</div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:20}}>
{isFolder ? '📁' : '📄'} {item.name}
</div>
{/* Neuer Link */}
<div style={{marginBottom:20}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8,letterSpacing:1}}>NEUEN LINK ERSTELLEN</div>
<input value={label} onChange={e=>setLabel(e.target.value)}
placeholder="Bezeichnung (optional)"
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} />
{/* Passwort Pflicht + Generator */}
<div style={{display:'flex',gap:6,marginBottom:8}}>
<div style={{flex:1}}>
<input
type="text"
value={password}
onChange={e=>setPassword(e.target.value)}
placeholder="Passwort (Pflicht)"
style={{...S.inp, width:'100%', boxSizing:'border-box', fontFamily:'monospace', letterSpacing:1}}
/>
</div>
<button onClick={()=>setPassword(genPw())} title="Passwort generieren"
style={{background:'rgba(245,158,11,0.12)',border:'1px solid rgba(245,158,11,0.3)',borderRadius:7,padding:'0 10px',color:'#f59e0b',cursor:'pointer',fontSize:14,flexShrink:0}}>
🔀
</button>
</div>
{/* Ablauf kein "Kein Ablauf" */}
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:12}}>
{expiryOptions.map(o => (
<button key={o.h} onClick={()=>setExpiresH(o.h)}
style={{
background: expiresH===o.h ? '#f59e0b22' : 'rgba(255,255,255,0.05)',
border: `1px solid ${expiresH===o.h ? '#f59e0b88' : 'rgba(255,255,255,0.1)'}`,
borderRadius:6, padding:'4px 10px',
color: expiresH===o.h ? '#f59e0b' : 'rgba(255,255,255,0.5)',
fontFamily:'monospace', fontSize:10, cursor:'pointer',
}}>
{o.label}
</button>
))}
</div>
<button onClick={createShare} disabled={creating||!password.trim()}
style={{...S.btn('#4ade80'), width:'100%', opacity:(creating||!password.trim())?0.5:1}}>
{creating ? '⏳ Erstelle...' : '🔗 Link erstellen'}
</button>
{newLink && (
<div style={{marginTop:10, padding:'10px 12px', background:'rgba(74,222,128,0.08)', border:'1px solid rgba(74,222,128,0.3)', borderRadius:8}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:4}}>LINK (in Zwischenablage)</div>
<div style={{color:'#4ade80',fontFamily:'monospace',fontSize:10,wordBreak:'break-all',marginBottom:6}}>{newLink}</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:2}}>PASSWORT</div>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<div style={{color:'#f59e0b',fontFamily:'monospace',fontSize:12,letterSpacing:2}}>{newPw}</div>
<button onClick={()=>navigator.clipboard?.writeText(newPw).then(()=>toast('Passwort kopiert ✓'))}
style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',borderRadius:5,padding:'2px 8px',color:'#f59e0b',fontFamily:'monospace',fontSize:9,cursor:'pointer'}}>
📋
</button>
</div>
</div>
)}
</div>
{/* Bestehende Links */}
{shares.length > 0 && (
<div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8,letterSpacing:1}}>AKTIVE LINKS</div>
{shares.map(s => (
<div key={s.id} style={{padding:'10px 12px',background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:8,marginBottom:8}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:4}}>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11}}>
{s.label || '—'} <span style={{color:'#f59e0b',fontSize:9}}>🔒</span>
</div>
<div style={{display:'flex',gap:6}}>
<button onClick={()=>copyLink(s.token)}
style={{background:'rgba(74,222,128,0.1)',border:'1px solid rgba(74,222,128,0.3)',borderRadius:5,padding:'3px 8px',color:'#4ade80',fontFamily:'monospace',fontSize:9,cursor:'pointer'}}>
📋 Kopieren
</button>
<button onClick={()=>deleteShare(s.id)}
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.3)',borderRadius:5,padding:'3px 8px',color:'#f87171',fontFamily:'monospace',fontSize:9,cursor:'pointer'}}>
</button>
</div>
</div>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9}}>
Ablauf: {fmtExp(s.expires_at)} · {s.download_count} Abrufe
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function ShareModal({ item, type, onClose, toast }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [shares, setShares] = useState([]);
const base = type==='folder' ? `/tools/dateien/folders/${item.id}` : `/tools/dateien/${item.id}`;
useEffect(() => { api(`${base}/shares`).then(setShares).catch(()=>{}); }, [base]);
const share = async () => {
if (!username.trim()) return;
try {
await api(`${base}/share`, { body: { username: username.trim(), password: password.trim() || undefined } });
toast('Geteilt ✓'); setUsername(''); setPassword('');
api(`${base}/shares`).then(setShares);
} catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`${base}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); toast('Freigabe entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const isMobile = window.innerWidth<768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth:440,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
{type==='folder'?'📁':'📎'} {item.name||item.originalname}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px'}}></button>
</div>
{/* Teilen-Formular */}
{type==='file' && (
<div style={{marginBottom:10}}>
<div style={{display:'flex',gap:8,marginBottom:6}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
<input value={password} onChange={e=>setPassword(e.target.value)}
placeholder="Passwort (optional)" type="text" autoCapitalize="none"
style={{...S.inp,fontSize:13,padding:'7px 10px'}}/>
{password && <div style={{color:'rgba(255,230,109,0.6)',fontSize:9,fontFamily:'monospace',marginTop:4}}>
Teile das Passwort separat mit dem Empfänger
</div>}
</div>
)}
{type==='folder' && (
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
)}
{/* Bestehende Shares */}
{shares.length>0 && (
<div>
<div style={{...S.head,marginBottom:6,marginTop:4}}>GETEILT MIT</div>
{shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<div>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
{s.has_password ? <span title="Passwortgeschützt" style={{fontSize:10}}>🔑</span> : null}
</div>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
{s.shared_at && `geteilt ${fmtDate(s.shared_at)}`}
{s.accessed_at
? <span style={{color:'rgba(78,205,196,0.7)',marginLeft:8}}> zugegriffen {fmtDate(s.accessed_at)}</span>
: <span style={{color:'rgba(255,255,255,0.25)',marginLeft:8}}> noch nicht geöffnet</span>
}
</div>
</div>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
))}
</div>
)}
{shares.length===0 && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,marginTop:4}}>Noch mit niemandem geteilt.</div>}
</div>
</div>
);
}
// ── Haupt-Komponente ──────────────────────────────────────────────────────────
// ── Passwort-Prompt Modal (ersetzt window.prompt kein 1Password-Trigger) ────
function PasswordPromptModal({ filename, onSubmit, onCancel }) {
const [pw, setPw] = useState('');
const isMobile = window.innerWidth < 768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:7000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onCancel()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth:400,padding:'22px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,marginBottom:6}}>
🔒 Passwortgeschützte Datei
</div>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,marginBottom:14,lineHeight:1.6}}>
{filename}
</div>
<input
autoFocus
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
value={pw}
onChange={e=>setPw(e.target.value)}
onKeyDown={e=>{ if(e.key==='Enter'&&pw)onSubmit(pw); if(e.key==='Escape')onCancel(); }}
placeholder="Passwort eingeben…"
style={{...S.inp,marginBottom:12,fontSize:14}}
/>
<div style={{display:'flex',gap:8}}>
<button onClick={()=>{if(pw)onSubmit(pw);}} disabled={!pw}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:pw?1:0.4}}>
Herunterladen
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
</div>
);
}
// ── Upload-Ordner & Share-Verwaltung ─────────────────────────────────────────
function UploadShareManager({ toast }) {
const [shares, setShares] = useState([]);
const [folder, setFolder] = useState(null);
const [logs, setLogs] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const [form, setForm] = useState({ password:'', expires_hours:1, max_size_mb:10 });
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(null);
const [sharePws, setSharePws] = useState({}); // {shareId: password} für Passwort-Kopieren
const load = () => api('/upload-shares').then(d=>{ setShares(d.shares||[]); setFolder(d.folder); }).catch(()=>{});
useEffect(()=>{ load(); },[]);
const genPw = () => {
const chars='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%';
setForm(p=>({...p, password: Array.from({length:12},()=>chars[Math.floor(Math.random()*chars.length)]).join('')}));
};
const create = async () => {
if (!form.password.trim()) { toast('Passwort erforderlich','error'); return; }
setBusy(true);
try {
const r = await api('/upload-shares', { body: form });
setSharePws(p=>({...p, [r.id]: form.password}));
toast('Upload-Link erstellt ✓'); setShowCreate(false); setForm({password:'',expires_hours:1,max_size_mb:10}); load();
} catch(e) { toast(e.message,'error'); }
setBusy(false);
};
const deactivate = async id => {
await api(`/upload-shares/${id}`, { method:'DELETE' });
load(); toast('Link deaktiviert');
};
const removeShare = async id => {
await api(`/upload-shares/${id}/remove`, { method:'DELETE' });
setShares(p => p.filter(s => s.id !== id));
toast('Entfernt');
};
const shorten = async (shareId, token) => {
setShortening(shareId);
const url = `${window.location.origin}/u/${token}`;
try {
// is.gd kostenloser URL-Shortener, kein API-Key nötig
const r = await fetch(`https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}`);
const short = await r.text();
if (short.startsWith('http')) {
setShortUrls(p=>({...p,[shareId]:short.trim()}));
navigator.clipboard?.writeText(short.trim()).catch(()=>{});
toast('Kurz-URL erstellt & kopiert ✓');
} else {
toast('Shortener nicht verfügbar','error');
}
} catch { toast('Shortener nicht erreichbar','error'); }
setShortening(null);
};
const copyLink = (token) => {
const url = `${window.location.origin}/u/${token}`;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).catch(() => {
const ta = document.createElement('textarea');
ta.value = url; ta.style.cssText='position:fixed;opacity:0.01';
document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
});
}
setCopied(token); setTimeout(()=>setCopied(null), 2500);
};
const loadLogs = () => {
api('/upload-shares/logs').then(setLogs).catch(()=>{});
setShowLogs(true);
};
const fmtDate = d => d ? new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '';
const fmtSize = b => b>1024*1024 ? (b/1024/1024).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB';
const DURATION_OPTS = [
[1,'1 Stunde'],[2,'2 Stunden'],[6,'6 Stunden'],[12,'12 Stunden'],
[24,'1 Tag'],[48,'2 Tage'],[72,'3 Tage'],[168,'1 Woche'],[336,'2 Wochen'],
];
const SIZE_OPTS = [1,2,5,10,20,50,100,250];
return (
<div>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12,flexWrap:'wrap',gap:8}}>
<div>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:14,fontWeight:700}}>
📤 Upload-Freigabe
</div>
{folder && <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
Dateien landen in: {folderIcon(folder)} {folder.name}
</div>}
</div>
<div style={{display:'flex',gap:6}}>
<button onClick={loadLogs} style={{
background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.15)',
borderRadius:6,color:'rgba(255,255,255,0.6)',cursor:'pointer',
fontFamily:'monospace',fontSize:11,padding:'6px 12px',whiteSpace:'nowrap'}}>
📋 Log
</button>
<button onClick={()=>setShowCreate(p=>!p)} style={{...S.btn('#4ecdc4'),padding:'7px 14px',fontSize:12}}>
{showCreate?'Abbrechen':'+ Link erstellen'}
</button>
</div>
</div>
{/* Erstellen-Formular */}
{showCreate && (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:12}}>UPLOAD-LINK KONFIGURIEREN</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:8}}>
<div>
<div style={{...S.head,fontSize:9,marginBottom:4}}>GÜLTIGKEITSDAUER</div>
<select value={form.expires_hours} onChange={e=>setForm(p=>({...p,expires_hours:Number(e.target.value)}))}
style={{...S.inp,width:'100%',cursor:'pointer'}}>
{DURATION_OPTS.map(([v,l])=><option key={v} value={v}>{l}</option>)}
</select>
</div>
<div>
<div style={{...S.head,fontSize:9,marginBottom:4}}>MAX. DATEIGRÖSSE</div>
<select value={form.max_size_mb} onChange={e=>setForm(p=>({...p,max_size_mb:Number(e.target.value)}))}
style={{...S.inp,width:'100%',cursor:'pointer'}}>
{SIZE_OPTS.map(v=><option key={v} value={v}>{v} MB</option>)}
</select>
</div>
</div>
<div style={{marginBottom:12}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>PASSWORT</div>
<div style={{display:'flex',gap:6}}>
<input value={form.password} onChange={e=>setForm(p=>({...p,password:e.target.value}))}
placeholder="Passwort vergeben" style={{...S.inp,flex:1,fontFamily:'monospace'}}/>
<button onClick={genPw} style={{...S.btn('#ffe66d',true),fontSize:11,padding:'0 12px',whiteSpace:'nowrap'}}>
Generieren
</button>
</div>
{form.password && <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginTop:4}}>
Passwort: <strong style={{color:'#ffe66d'}}>{form.password}</strong> notiere es jetzt
</div>}
</div>
<button onClick={create} disabled={busy} style={{...S.btn('#4ecdc4'),width:'100%',textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':'🔗 Link erstellen'}
</button>
</div>
)}
{/* Aktive Links */}
{shares.length===0 && !showCreate && (
<div style={{...S.card,textAlign:'center',padding:32}}>
<div style={{fontSize:28,marginBottom:8}}>📤</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
Noch kein Upload-Link. Erstelle einen Link um anderen das Hochladen zu erlauben.
</div>
</div>
)}
{shares.map(s => {
const expired = new Date(s.expires_at) < new Date();
return (
<div key={s.id} style={{...S.card,marginBottom:8,opacity:(!s.is_active||expired)?0.5:1}}>
<div style={{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
<span style={{color:!s.is_active?'#ff6b9d':expired?'#ffe66d':'#4ecdc4',fontFamily:'monospace',fontSize:11,
border:`1px solid currentColor`,borderRadius:4,padding:'1px 6px',flexShrink:0}}>
{!s.is_active?(s.deactivated_reason==='too_many_attempts'?'⚠ Gesperrt (Fehlversuche)':'Deaktiviert'):expired?'Abgelaufen':'Aktiv'}
</span>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
bis {fmtDate(s.expires_at)} · max {s.max_size_mb} MB/Datei · {s.upload_count||0} Upload(s)
</span>
</div>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10,marginTop:3,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
/u/{s.token}
</div>
</div>
<div style={{display:'flex',gap:5,flexShrink:0}}>
{!!s.is_active && !expired && (
<>
<button onClick={()=>copyLink(s.token)}
style={{...S.btn(copied===s.token?'#4ecdc4':'#ffe66d',copied!==s.token),fontSize:10,padding:'4px 10px'}}>
{copied===s.token?'✓ Kopiert':'📋 Link'}
</button>
{sharePws[s.id] && (
<button onClick={()=>{ navigator.clipboard?.writeText(sharePws[s.id]).then(()=>toast('Passwort kopiert ✓')).catch(()=>toast(sharePws[s.id])); }}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🔑 PW</button>
)}
</>
)}
{!!s.is_active && (
<button onClick={()=>deactivate(s.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>Deaktivieren</button>
)}
{!s.is_active && (
<button onClick={()=>removeShare(s.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}> Entfernen</button>
)}
</div>
</div>
</div>
);
})}
{/* Log-Ansicht */}
{showLogs && (
<div style={{marginTop:12,background:'#131316',border:'1px solid rgba(255,255,255,0.08)',borderRadius:10,padding:'12px 14px'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
<div style={{...S.head,marginBottom:0}}>UPLOAD-PROTOKOLL</div>
<button onClick={()=>setShowLogs(false)} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:16}}></button>
</div>
{logs.length===0
? <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Noch keine Uploads über Freigabe-Links.</div>
: logs.map(l=>(
<div key={l.id} style={{display:'flex',gap:10,padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{fontSize:14,flexShrink:0}}>📄</span>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.originalname}</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>{fmtDate(l.uploaded_at)} · {fmtSize(l.size)}</div>
</div>
</div>
))
}
</div>
)}
</div>
);
}
function folderIcon(folder) {
if (folder?.is_upload_folder) return '📤';
if (folder?.name === '3D-Modelle' || folder?.name?.startsWith('3D')) return '📦';
return '📁';
}
export default function Dateien({ toast, mobile }) {
const [data, setData] = useState({own:[],shared:[],sharedByMe:[]});
const [folders, setFolders] = useState({own:[],shared:[],sharedByMe:[]});
const [loading, setLoading] = useState(true);
const [currentFolderId, setCurrentFolderId] = useState(null);
const [folderPath, setFolderPath] = useState([]);
const [tab, setTab] = useState('own');
const [search, setSearch] = useState('');
const [shareItem, setShareItem] = useState(null);
const [publicShareItem, setPublicShareItem] = useState(null);
const [publicShares, setPublicShares] = useState([]);
const [showPublicLinks, setShowPublicLinks] = useState(false);
const [pwPrompt, setPwPrompt] = useState(null); // {file}
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [newFolder, setNewFolder] = useState(false);
const [folderName, setFolderName] = useState('');
const [storage, setStorage] = useState(null);
const fileRef = useRef(null);
const { confirm, ConfirmDialog } = useConfirm();
const load = useCallback(() => {
setLoading(true);
api('/tools/dateien/public-shares').then(d => setPublicShares(d.shares||[])).catch(()=>{});
const fq = currentFolderId ? `?folder_id=${currentFolderId}` : '';
const pq = currentFolderId ? `?parent_id=${currentFolderId}` : '';
Promise.all([api(`/tools/dateien${fq}`), api(`/tools/dateien/folders${pq}`)])
.then(([d,f]) => {
setData({own:d.own||[],shared:d.shared||[],sharedByMe:d.sharedByMe||[]});
setFolders({own:f.own||[],shared:f.shared||[],sharedByMe:f.sharedByMe||[]});
})
.catch(e=>toast(e.message,'error'))
.finally(()=>setLoading(false));
api('/tools/dateien/storage').then(setStorage).catch(()=>{});
}, [currentFolderId]);
useEffect(()=>{load();},[load]);
// "3D-Modelle" Ordner beim ersten Laden automatisch anlegen
useEffect(()=>{
api('/tools/dateien/ensure-folder',{body:{name:'3D-Modelle'}}).catch(()=>{});
},[]);
const doUpload = async file => {
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append('file', file);
if (currentFolderId) form.append('folder_id', currentFolderId);
await api('/tools/dateien',{method:'POST',body:form,isFile:true});
toast(`${file.name} hochgeladen ✓`); load();
} catch(e) { toast(e.message,'error'); } finally { setUploading(false); }
};
const onFileInput = e => { doUpload(e.target.files?.[0]); e.target.value=''; };
const onDrop = e => { e.preventDefault(); setDragOver(false); doUpload(e.dataTransfer.files?.[0]); };
const download = async (file, pw) => {
try {
const url = `/api/tools/dateien/${file.id}/download${pw ? `?password=${encodeURIComponent(pw)}` : ''}`;
const res = await fetch(url, { headers:{Authorization:`Bearer ${localStorage.getItem('sk_token')}`} });
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Fehler'}));
if (err.passwordRequired) {
setPwPrompt(file);
return;
}
if (pw && res.status===403) { toast('Falsches Passwort', 'error'); return; }
toast(err.error,'error'); return;
}
const blob = await res.blob();
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob), download: file.originalname,
});
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
} catch(e){toast(e.message,'error');}
};
const delFile = async file => {
if (!await confirm(`"${file.originalname}" löschen?`)) return;
try{await api(`/tools/dateien/${file.id}`,{method:'DELETE'});toast('Gelöscht');load();}
catch(e){toast(e.message,'error');}
};
const delFolder = async folder => {
if (!await confirm(`Ordner "${folder.name}" und alle Inhalte löschen?`)) return;
try{await api(`/tools/dateien/folders/${folder.id}`,{method:'DELETE'});toast('Gelöscht');load();}
catch(e){toast(e.message,'error');}
};
const createFolder = async () => {
if (!folderName.trim()) return;
try {
const body={name:folderName.trim()};
if (currentFolderId) body.parent_id=currentFolderId;
await api('/tools/dateien/folders',{body});
toast('Ordner erstellt'); setFolderName(''); setNewFolder(false); load();
} catch(e){toast(e.message,'error');}
};
const enterFolder = (folder, isShared=false) => {
setCurrentFolderId(folder.id);
setFolderPath(p=>[...p,{id:folder.id,name:folder.name,isShared}]);
setSearch('');
};
const navigateTo = idx => {
if (idx<0){setCurrentFolderId(null);setFolderPath([]);}
else{const t=folderPath[idx];setCurrentFolderId(t.id);setFolderPath(p=>p.slice(0,idx+1));}
setSearch('');
};
const sq = search.toLowerCase();
const getFolders = () => tab==='sharedByMe'?(folders.sharedByMe||[]):tab==='shared'?(folders.shared||[]):folders.own;
const getFiles = () => tab==='sharedByMe'?(data.sharedByMe||[]):tab==='shared'?data.shared:data.own;
const filtFolders = sq ? getFolders().filter(f=>f.name.toLowerCase().includes(sq)) : getFolders();
const filtFiles = sq ? getFiles().filter(f=>f.originalname.toLowerCase().includes(sq)) : getFiles();
const totalSize = data.own.reduce((s,f)=>s+f.size,0);
const inSharedFolder = folderPath.some(p=>p.isShared);
const isOwn = tab==='own' && !inSharedFolder;
const sharedCnt = data.shared.length + (folders.shared||[]).length;
const byMeCnt = (data.sharedByMe||[]).length + (folders.sharedByMe||[]).length;
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:960}}>
<ConfirmDialog/>
{/* ── Speicherplatz ─────────────────────────────────────────────────── */}
{storage && (
<div style={{marginBottom:14,padding:'10px 12px',background:'rgba(255,255,255,0.02)',
border:'1px solid rgba(255,255,255,0.06)',borderRadius:8}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',
color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>
<span>{fmtSize(storage.used)} / {storage.totalLimitMb} MB belegt</span>
<span style={{fontSize:9,color:'rgba(255,255,255,0.25)'}}>max. {storage.maxSizeMb} MB pro Datei · {storage.count} Dateien</span>
</div>
<div style={{height:3,borderRadius:3,background:'rgba(255,255,255,0.06)',overflow:'hidden',marginTop:7}}>
<div style={{
height:'100%', borderRadius:3,
width:`${Math.min(100,(storage.used/(storage.totalLimitMb*1024*1024))*100)}%`,
background: storage.used/(storage.totalLimitMb*1024*1024) > 0.85 ? '#ff6b9d' :
storage.used/(storage.totalLimitMb*1024*1024) > 0.6 ? '#ffe66d' : '#4ecdc4',
transition:'width 0.4s',
}}/>
</div>
</div>
)}
{shareItem && <ShareModal item={shareItem.item} type={shareItem.type} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{publicShareItem && <PublicShareModal item={publicShareItem.item} itemType={publicShareItem.type} onClose={()=>{setPublicShareItem(null);api('/tools/dateien/public-shares').then(d=>setPublicShares(d.shares||[])).catch(()=>{});}} toast={toast}/>}
{/* ── Aktive öffentliche Links ──────────────────────────────────────── */}
{publicShares.length > 0 && tab === 'own' && (
<div style={{marginTop:24,padding:'14px 16px',background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',borderRadius:10}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:showPublicLinks?12:0,cursor:'pointer'}}
onClick={()=>setShowPublicLinks(v=>!v)}>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<span style={{color:'#4ade80',fontSize:14}}>🔗</span>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12}}>
Aktive öffentliche Links
</span>
<span style={{background:'rgba(74,222,128,0.15)',border:'1px solid rgba(74,222,128,0.3)',borderRadius:10,padding:'1px 7px',color:'#4ade80',fontFamily:'monospace',fontSize:10}}>
{publicShares.length}
</span>
</div>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11}}>{showPublicLinks ? '▲' : '▼'}</span>
</div>
{showPublicLinks && publicShares.map(s => {
const name = s.file_name || s.folder_name || s.label || '—';
const isExp = s.expires_at && s.expires_at < Math.floor(Date.now()/1000);
const fmtExp = exp => {
if (!exp) return '∞';
const d = new Date(exp*1000);
return d.toLocaleDateString('de-DE')+' '+d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
};
return (
<div key={s.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 10px',
background:'rgba(255,255,255,0.03)',border:`1px solid ${isExp?'rgba(248,113,113,0.3)':'rgba(255,255,255,0.07)'}`,
borderRadius:7,marginBottom:6}}>
<span style={{fontSize:14}}>{s.file_id ? '📄' : '📁'}</span>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:11,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{name}
{s.label && s.label !== name && <span style={{color:'rgba(255,255,255,0.35)',marginLeft:6}}>({s.label})</span>}
</div>
<div style={{color:isExp?'#f87171':'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9}}>
{isExp?'⚠ Abgelaufen':'Ablauf: '}{!isExp&&fmtExp(s.expires_at)} · {s.download_count} Abrufe · 🔒
</div>
</div>
<button onClick={()=>{
navigator.clipboard?.writeText(window.location.origin+'/s/'+s.token)
.then(()=>toast('Link kopiert ✓')).catch(()=>toast(window.location.origin+'/s/'+s.token));
}}
style={{background:'rgba(74,222,128,0.1)',border:'1px solid rgba(74,222,128,0.3)',borderRadius:5,padding:'4px 8px',color:'#4ade80',fontFamily:'monospace',fontSize:9,cursor:'pointer',flexShrink:0}}>
📋
</button>
<button onClick={async()=>{
await api(`/tools/dateien/public-shares/${s.id}`,{method:'DELETE'});
setPublicShares(p=>p.filter(x=>x.id!==s.id));
toast('Link gelöscht');
}}
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.3)',borderRadius:5,padding:'4px 8px',color:'#f87171',fontFamily:'monospace',fontSize:9,cursor:'pointer',flexShrink:0}}>
</button>
</div>
);
})}
</div>
)}
{pwPrompt && <PasswordPromptModal
filename={pwPrompt.originalname}
onSubmit={pw=>{ setPwPrompt(null); download(pwPrompt, pw); }}
onCancel={()=>setPwPrompt(null)}
/>}
<input ref={fileRef} type="file" onChange={onFileInput} style={{display:'none'}}/>
{/* ── Seitentitel ──────────────────────────────────────────────────── */}
<div style={{marginBottom:16,display:'flex',alignItems:'flex-start',justifyContent:'space-between'}}>
<div>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?18:22,margin:0}}>Dateien</h1>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10,marginTop:3}}>
{data.own.length} Dateien · {fmtSize(totalSize)}
</div>
</div>
<button onClick={load} title="Neu laden"
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,
color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace',marginTop:2}}></button>
</div>
{/* ── Tabs + Neu-Ordner (überall gleich) ───────────────────────────── */}
<div style={{display:'flex',gap:6,marginBottom:12,alignItems:'center',flexWrap:'wrap'}}>
{[
['own','Dateien', null],
['shared','Geteilt mit mir', sharedCnt||null],
['sharedByMe','Geteilt von mir', byMeCnt||null],
['upload','📤 Upload-Freigabe', null],
].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setCurrentFolderId(null);setFolderPath([]);}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
border:`1px solid ${tab===k?'rgba(78,205,196,0.5)':'rgba(255,255,255,0.12)'}`,
background:tab===k?'rgba(78,205,196,0.12)':'transparent',
color:tab===k?'#4ecdc4':'rgba(255,255,255,0.55)',
}}>{l}{cnt?` (${cnt})`:''}</button>
))}
{/* Ordner-Button immer am Ende der Tab-Reihe wenn own-Tab aktiv */}
{isOwn && !newFolder && (
<button onClick={()=>setNewFolder(true)} style={{
marginLeft:'auto',padding:'6px 12px',borderRadius:20,fontFamily:'monospace',fontSize:11,
cursor:'pointer',border:'1px solid rgba(255,255,255,0.15)',
background:'rgba(255,255,255,0.05)',color:'rgba(255,255,255,0.65)',
display:'flex',alignItems:'center',gap:5,
}}>📁 + Ordner</button>
)}
</div>
{/* ── Breadcrumb ────────────────────────────────────────────────────── */}
{folderPath.length>0 && (
<div style={{display:'flex',alignItems:'center',gap:4,marginBottom:12,flexWrap:'wrap'}}>
<button onClick={()=>navigateTo(-1)} style={{background:'transparent',border:'none',
color:'rgba(255,255,255,0.5)',cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'2px 6px'}}>
Dateien
</button>
{folderPath.map((p,i)=>(
<span key={p.id} style={{display:'flex',alignItems:'center',gap:4}}>
<ChevronIcon size={11} color="rgba(255,255,255,0.25)" dir="right"/>
<button onClick={()=>navigateTo(i)} style={{
background:i===folderPath.length-1?'rgba(78,205,196,0.1)':'transparent',
border:i===folderPath.length-1?'1px solid rgba(78,205,196,0.2)':'none',
borderRadius:6,color:i===folderPath.length-1?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'2px 8px'}}>
{p.name}
</button>
</span>
))}
</div>
)}
{/* ── Neuer Ordner Formular ─────────────────────────────────────────── */}
{newFolder && (
<div style={{...S.card,marginBottom:10,display:'flex',gap:8,alignItems:'center'}}>
<span style={{fontSize:18}}>📁</span>
<input value={folderName} onChange={e=>setFolderName(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter')createFolder();if(e.key==='Escape'){setNewFolder(false);setFolderName('');}}}
placeholder="Ordnername…" autoFocus style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={createFolder} style={S.btn('#4ecdc4')}></button>
<button onClick={()=>{setNewFolder(false);setFolderName('');}} style={S.btn('#ff6b9d',true)}></button>
</div>
)}
{/* ── Upload-Ordner Tab ─────────────────────────────────────────────── */}
{tab==='upload' && <UploadShareManager toast={toast}/>}
{/* ── Suche + Upload-Zone ───────────────────────────────────────────── */}
{tab!=='upload' && <div style={{display:mobile?'block':'flex',gap:8,marginBottom:12}}>
<div style={{position:'relative',flex:1,marginBottom:mobile?8:0}}>
<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="Suchen…"
style={{...S.inp,paddingLeft:34,fontSize:15}}/>
{search&&<button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',
transform:'translateY(-50%)',background:'transparent',border:'none',
color:'rgba(255,255,255,0.3)',cursor:'pointer',fontSize:16}}></button>}
</div>
{/* Drop-Zone */}
<div onDragOver={e=>{e.preventDefault();setDragOver(true);}} onDragLeave={()=>setDragOver(false)}
onDrop={onDrop} onClick={()=>fileRef.current?.click()}
style={{
display:'flex',alignItems:'center',justifyContent:'center',gap:8,
padding:'0 18px',height:44,borderRadius:10,cursor:'pointer',
border:`2px dashed ${dragOver?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
background:dragOver?'rgba(78,205,196,0.08)':'transparent',
transition:'all 0.15s',flexShrink:0,
minWidth:mobile?'100%':200,
}}>
<UploadIcon size={14} color={dragOver?'#4ecdc4':'rgba(255,255,255,0.4)'}/>
<span style={{color:dragOver?'#4ecdc4':'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11}}>
{uploading?'Hochladen…':dragOver?'Loslassen':mobile?'Datei hochladen':'Hier ablegen oder klicken'}
</span>
</div>
</div>}
{loading&&tab!=='upload'&&<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',padding:'8px 0'}}>Lädt</div>}
{/* ── Ordner ────────────────────────────────────────────────────────── */}
{tab!=='upload' && filtFolders.map(folder => {
const canManage = !folder.owner || tab==='sharedByMe';
return (
<div key={folder.id} style={{...S.card,marginBottom:6,padding:'10px 14px',
display:'flex',alignItems:'center',gap:10}}>
<span style={{fontSize:22,flexShrink:0}}>{folderIcon(folder)}</span>
{/* Info klickbar zum Navigieren */}
<div style={{flex:1,minWidth:0,cursor:'pointer'}} onClick={()=>enterFolder(folder,!!folder.owner)}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</div>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
{[
folder.owner&&tab!=='sharedByMe'?`von ${folder.owner}`:null,
folder.subCount>0?`${folder.subCount} Ordner`:null,
folder.fileCount>0?`${folder.fileCount} Datei${folder.fileCount!==1?'en':''}`:null,
folder.fileCount>0&&folder.totalSize>0?fmtSize(folder.totalSize):null,
folder.subCount===0&&folder.fileCount===0?'leer':null,
tab==='sharedByMe'&&folder.shared_with_names?`${folder.shared_with_names}`:null,
].filter(Boolean).join(' · ')}
</div>
</div>
{/* Aktionen feste Breite, symmetrisch */}
<div style={{display:'flex',gap:5,flexShrink:0,alignItems:'center'}}>
{canManage && (
<><IconBtn onClick={()=>setShareItem({item:folder,type:'folder'})} color="#ffe66d" title="Teilen" stop>🤝</IconBtn>
<IconBtn onClick={e=>{e.stopPropagation();setPublicShareItem({item:folder,type:'folder'});}} color="#4ade80" title="Öffentlicher Link" stop>🔗</IconBtn></>
)}
{canManage&&tab==='own' && (
<IconBtn onClick={()=>delFolder(folder)} color="#ff6b9d" title="Löschen" stop>
<TrashIcon size={13} color="#ff6b9d"/>
</IconBtn>
)}
<div style={{width:24,display:'flex',alignItems:'center',justifyContent:'center'}}>
<ChevronIcon size={14} color="rgba(255,255,255,0.3)" dir="right"/>
</div>
</div>
</div>
);
})}
{/* ── Dateien ───────────────────────────────────────────────────────── */}
{tab!=='upload' && filtFiles.map(file => {
const canManage = isOwn || tab==='sharedByMe';
return (
<div key={file.id} style={{...S.card,marginBottom:6,padding:'10px 14px',
display:'flex',alignItems:'center',gap:10}}>
<span style={{fontSize:22,flexShrink:0}}>{extIcon(file.originalname)}</span>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',display:'flex',alignItems:'center',gap:5}}>
<span style={{overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{file.originalname}</span>
{file.has_password ? <span title="Passwortgeschützt" style={{fontSize:11,flexShrink:0}}>🔒</span> : null}
</div>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
{fmtSize(file.size)} · {fmtDate(file.created_at)}
{tab==='shared'?` · von ${file.owner}`:''}
{tab==='sharedByMe'&&file.shared_with_names?(
<span>
{` · → ${file.shared_with_names}`}
{file.accessed_at
? <span style={{color:'rgba(78,205,196,0.8)'}}> · {fmtDate(file.accessed_at)}</span>
: <span style={{color:'rgba(255,255,255,0.25)'}}> · nicht geöffnet</span>
}
</span>
):''}
</div>
</div>
{/* Aktionen gleiche Breite wie bei Ordnern */}
<div style={{display:'flex',gap:5,flexShrink:0,alignItems:'center'}}>
<IconBtn onClick={()=>download(file)} color="#4ecdc4" title="Herunterladen">
<DownloadIcon size={13} color="#4ecdc4"/>
</IconBtn>
{canManage && (
<><IconBtn onClick={()=>setShareItem({item:file,type:'file'})} color="#ffe66d" title="Teilen">🤝</IconBtn>
<IconBtn onClick={()=>setPublicShareItem({item:file,type:'file'})} color="#4ade80" title="Öffentlicher Link">🔗</IconBtn></>
)}
{isOwn && (
<IconBtn onClick={()=>delFile(file)} color="#ff6b9d" title="Löschen">
<TrashIcon size={13} color="#ff6b9d"/>
</IconBtn>
)}
{/* Platzhalter damit Dateien-Zeile gleich breit wie Ordner-Zeile */}
<div style={{width:24}}/>
</div>
</div>
);
})}
{!loading&&filtFolders.length===0&&filtFiles.length===0&&(
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>📂</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
{search?'Keine Treffer.':tab==='own'?'Noch leer Datei hochladen oder Ordner anlegen.':tab==='upload'?'':' Noch nichts geteilt.'}
</div>
</div>
)}
</div>
);
}
// ── Datei-Einstellungen (Admin) ───────────────────────────────────────────────
export function DateiSettings({ toast }) {
const [s, setS] = useState({file_max_size_mb:'50',file_total_size_mb:'500',file_allowed_ext:'.pdf,.jpg,.jpeg,.png,.gif,.zip,.txt,.docx,.xlsx,.mp4,.stl,.3mf'});
const [busy, setBusy] = useState(false);
useEffect(()=>{api('/tools/dateien/settings').then(d=>setS(prev=>({...prev,...d}))).catch(()=>{});},[]);
const save = async()=>{
setBusy(true);
try{await api('/tools/dateien/settings',{method:'PUT',body:s});toast('Gespeichert ✓');}
catch(e){toast(e.message,'error');}finally{setBusy(false);}
};
return (
<div>
<div style={{marginBottom:10}}>
<label style={{...S.head,display:'block',marginBottom:4}}>MAX. DATEIGRÖSSE PRO UPLOAD (MB)</label>
<input type="number" value={s.file_max_size_mb} onChange={e=>setS(p=>({...p,file_max_size_mb:e.target.value}))} style={{...S.inp,fontSize:15}} min="1"/>
</div>
<div style={{marginBottom:10}}>
<label style={{...S.head,display:'block',marginBottom:4}}>GESAMTLIMIT PRO BENUTZER (MB)</label>
<input type="number" value={s.file_total_size_mb} onChange={e=>setS(p=>({...p,file_total_size_mb:e.target.value}))} style={{...S.inp,fontSize:15}} min="1"/>
</div>
<div style={{marginBottom:14}}>
<label style={{...S.head,display:'block',marginBottom:4}}>ERLAUBTE ENDUNGEN (kommagetrennt)</label>
<input value={s.file_allowed_ext} onChange={e=>setS(p=>({...p,file_allowed_ext:e.target.value}))} autoCapitalize="none" style={{...S.inp,fontSize:13}}/>
</div>
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),width:'100%',textAlign:'center',padding:'10px 0'}}>
{busy?'…':'✓ Speichern'}
</button>
</div>
);
}