Dateien nach "frontend/src/tools" hochladen
This commit is contained in:
291
frontend/src/tools/dateien.jsx
Normal file
291
frontend/src/tools/dateien.jsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api, S } from '../lib.js';
|
||||
import { useConfirm } from '../confirm.jsx';
|
||||
import { UploadIcon, DownloadIcon, TrashIcon, SearchIcon, XIcon, PlusIcon } from '../icons.jsx';
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024*1024) return `${(bytes/1024).toFixed(1)} KB`;
|
||||
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
return new Date(s).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
|
||||
}
|
||||
|
||||
const extIcon = name => {
|
||||
const ext = name.split('.').pop().toLowerCase();
|
||||
if (['jpg','jpeg','png','gif','webp'].includes(ext)) return '🖼';
|
||||
if (['pdf'].includes(ext)) return '📄';
|
||||
if (['zip','rar','7z'].includes(ext)) return '📦';
|
||||
if (['mp4','mov','avi'].includes(ext)) return '🎬';
|
||||
if (['mp3','wav'].includes(ext)) return '🎵';
|
||||
if (['stl','3mf'].includes(ext)) return '🖨';
|
||||
if (['txt','md'].includes(ext)) return '📝';
|
||||
if (['xlsx','csv'].includes(ext)) return '📊';
|
||||
if (['docx','doc'].includes(ext)) return '📃';
|
||||
return '📎';
|
||||
};
|
||||
|
||||
function ShareModal({ file, onClose, toast }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [shares, setShares] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api('/tools/dateien').then(d => {
|
||||
// find current shares for this file
|
||||
const allShares = d.own.find(f => f.id === file.id);
|
||||
// get shares via separate query approach - just reload full list
|
||||
}).catch(() => {});
|
||||
// Load shares from API
|
||||
api(`/tools/dateien/${file.id}/shares`).then(setShares).catch(() => {});
|
||||
}, [file.id]);
|
||||
|
||||
const share = async () => {
|
||||
if (!username.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/tools/dateien/${file.id}/share`, { body: { username: username.trim() } });
|
||||
toast(`Mit ${username} geteilt ✓`);
|
||||
setUsername('');
|
||||
api(`/tools/dateien/${file.id}/shares`).then(setShares).catch(() => {});
|
||||
} catch(e) { toast(e.message, 'error'); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const unshare = async userId => {
|
||||
try {
|
||||
await api(`/tools/dateien/${file.id}/share/${userId}`, { method: 'DELETE' });
|
||||
setShares(p => p.filter(s => s.id !== userId));
|
||||
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:460, 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 16px' }}/>}
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:14 }}>
|
||||
<div style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>
|
||||
Teilen: {file.originalname}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)', cursor:'pointer', fontSize:18 }}>✕</button>
|
||||
</div>
|
||||
<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} disabled={busy} style={{ ...S.btn('#4ecdc4'), padding:'0 14px' }}>
|
||||
{busy?'…':'Teilen'}
|
||||
</button>
|
||||
</div>
|
||||
{shares.length > 0 && (
|
||||
<div>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>GETEILT MIT</div>
|
||||
{shares.map(s => (
|
||||
<div key={s.id} style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
padding:'7px 0', borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
|
||||
<span style={{ color:'rgba(255,255,255,0.75)', fontFamily:'monospace', fontSize:13 }}>{s.username}</span>
|
||||
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d', true)}>✕ Entfernen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dateien({ toast, mobile }) {
|
||||
const [data, setData] = useState({ own:[], shared:[] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState('own');
|
||||
const [shareFile, setShareFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef(null);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api('/tools/dateien').then(setData).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false));
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
const upload = async e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
setUploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
await api('/tools/dateien', { method:'POST', body:form, isFile:true });
|
||||
toast(`${file.name} hochgeladen ✓`);
|
||||
load();
|
||||
} catch(err) { toast(err.message, 'error'); } finally { setUploading(false); }
|
||||
};
|
||||
|
||||
const download = async file => {
|
||||
const token = localStorage.getItem('sk_token');
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/tools/dateien/${file.id}/download?token=${token}`;
|
||||
// Use auth header approach via fetch blob
|
||||
try {
|
||||
const res = await fetch(`/api/tools/dateien/${file.id}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) { toast('Download fehlgeschlagen', 'error'); return; }
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
Object.assign(document.createElement('a'), { href:url, download:file.originalname }).click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const del = async file => {
|
||||
if (!await confirm(`"${file.originalname}" wirklich löschen?`)) return;
|
||||
try { await api(`/tools/dateien/${file.id}`, { method:'DELETE' }); toast('Gelöscht'); load(); }
|
||||
catch(e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const sq = search.toLowerCase();
|
||||
const list = tab === 'own'
|
||||
? data.own.filter(f => !sq || f.originalname.toLowerCase().includes(sq))
|
||||
: data.shared.filter(f => !sq || f.originalname.toLowerCase().includes(sq));
|
||||
|
||||
const totalSize = data.own.reduce((s,f) => s+f.size, 0);
|
||||
|
||||
return (
|
||||
<div style={{ padding: mobile?'14px 14px 90px':'36px 44px', maxWidth:900 }}>
|
||||
<ConfirmDialog/>
|
||||
{shareFile && <ShareModal file={shareFile} onClose={()=>{setShareFile(null);load();}} toast={toast}/>}
|
||||
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:14 }}>
|
||||
<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>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<input ref={fileRef} type="file" onChange={upload} style={{ display:'none' }}/>
|
||||
<button onClick={()=>fileRef.current?.click()} disabled={uploading}
|
||||
style={{ ...S.btn('#4ecdc4'), display:'flex', alignItems:'center', gap:6 }}>
|
||||
<UploadIcon size={14} color="#4ecdc4"/>
|
||||
{uploading ? 'Lädt…' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display:'flex', gap:6, marginBottom:12 }}>
|
||||
{[['own','Meine Dateien'],['shared','Geteilt mit mir']].map(([k,l]) => (
|
||||
<button key={k} onClick={()=>setTab(k)} style={{
|
||||
padding:'5px 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.1)'}`,
|
||||
background: tab===k?'rgba(78,205,196,0.12)':'transparent',
|
||||
color: tab===k?'#4ecdc4':'rgba(255,255,255,0.5)',
|
||||
}}>{l} {k==='shared'&&data.shared.length>0?`(${data.shared.length})`:''}</button>
|
||||
))}
|
||||
</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="Datei 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>
|
||||
|
||||
{loading && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace' }}>Lädt…</div>}
|
||||
|
||||
{!loading && list.length === 0 && (
|
||||
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
|
||||
<div style={{ fontSize:28, marginBottom:10 }}>{tab==='own'?'📂':'🤝'}</div>
|
||||
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:12 }}>
|
||||
{tab==='own' ? 'Noch keine Dateien. Klicke auf "Hochladen".' : 'Noch keine Dateien geteilt.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.map(file => (
|
||||
<div key={file.id} style={{ ...S.card, marginBottom:8, padding:'12px 14px',
|
||||
display:'flex', alignItems:'center', gap:12 }}>
|
||||
<span style={{ fontSize:24, 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' }}>{file.originalname}</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==='own' && file.shared_count > 0 && ` · geteilt mit ${file.shared_count}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:6, flexShrink:0 }}>
|
||||
<button onClick={()=>download(file)} style={S.btn('#4ecdc4', true)} title="Herunterladen">
|
||||
<DownloadIcon size={14} color="#4ecdc4"/>
|
||||
</button>
|
||||
{tab==='own' && <>
|
||||
<button onClick={()=>setShareFile(file)} style={S.btn('#ffe66d', true)} title="Teilen">
|
||||
🤝
|
||||
</button>
|
||||
<button onClick={()=>del(file)} style={S.btn('#ff6b9d', true)} title="Löschen">
|
||||
<TrashIcon size={14} color="#ff6b9d"/>
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Datei-Einstellungen (Admin) ───────────────────────────────────────────────
|
||||
export function DateiSettings({ toast }) {
|
||||
const [s, setS] = useState({ file_max_size_mb:'50', file_max_count:'20', 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(setS).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:12 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>MAX. DATEIGRÖSSE (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" max="500"/>
|
||||
</div>
|
||||
<div style={{ marginBottom:12 }}>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:4 }}>MAX. ANZAHL DATEIEN PRO BENUTZER</label>
|
||||
<input type="number" value={s.file_max_count} onChange={e=>setS(p=>({...p,file_max_count: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}))}
|
||||
placeholder=".pdf,.jpg,.png" 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user