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

787 lines
42 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>
);
// ── 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 [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);
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}/>}
{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>
)}
{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>
)}
{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>
);
}