feat: öffentliche Datei/Ordner-Freigabe mit Link, Ablauf & Passwort; Paywall-Killer in Suche

This commit is contained in:
2026-06-13 10:53:32 +02:00
parent b9a596a3b5
commit c3140aab7e
4 changed files with 543 additions and 0 deletions

View File

@@ -26,6 +26,160 @@ const IconBtn = ({ onClick, color, title, children, stop }) => (
</button>
);
// ── Öffentlicher Link-Share Modal ─────────────────────────────────────────────
function PublicShareModal({ item, itemType, onClose, toast }) {
const [password, setPassword] = useState('');
const [expiresH, setExpiresH] = useState(24);
const [label, setLabel] = useState('');
const [shares, setShares] = useState([]);
const [creating, setCreating] = useState(false);
const [newLink, setNewLink] = useState(null);
const isFolder = itemType === 'folder';
const base = '/tools/dateien/public-shares';
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() {
setCreating(true);
try {
const body = {
[isFolder ? 'folder_id' : 'file_id']: item.id,
password: password || undefined,
expires_hours: expiresH > 0 ? expiresH : undefined,
label: label || undefined,
};
const d = await api(base, { body });
const link = `${window.location.origin}/s/${d.token}`;
setNewLink(link);
setShares(p => [d.share, ...p]);
navigator.clipboard?.writeText(link).catch(()=>{});
toast('Link erstellt & kopiert ✓');
} 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 },
{ label:'Kein Ablauf', h:0 },
];
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>
{/* Label */}
<input value={label} onChange={e=>setLabel(e.target.value)}
placeholder="Bezeichnung (optional)"
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} />
{/* Passwort */}
<input type="password" value={password} onChange={e=>setPassword(e.target.value)}
placeholder="Passwort (optional)"
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} />
{/* 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}
style={{...S.btn('#4ade80'), width:'100%', opacity:creating?0.5:1}}>
{creating ? '⏳ Erstelle...' : '🔗 Link erstellen'}
</button>
{newLink && (
<div style={{marginTop:10, padding:'8px 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 (kopiert)</div>
<div style={{color:'#4ade80',fontFamily:'monospace',fontSize:10,wordBreak:'break-all'}}>{newLink}</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 || '—'}
{!!s.password_hash && <span style={{marginLeft:6,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('');
@@ -405,6 +559,7 @@ export default function Dateien({ toast, mobile }) {
const [tab, setTab] = useState('own');
const [search, setSearch] = useState('');
const [shareItem, setShareItem] = useState(null);
const [publicShareItem, setPublicShareItem] = useState(null);
const [pwPrompt, setPwPrompt] = useState(null); // {file}
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
@@ -542,6 +697,7 @@ export default function Dateien({ toast, mobile }) {
</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)} toast={toast}/>}
{pwPrompt && <PasswordPromptModal
filename={pwPrompt.originalname}
onSubmit={pw=>{ setPwPrompt(null); download(pwPrompt, pw); }}
@@ -681,6 +837,7 @@ export default function Dateien({ toast, mobile }) {
<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>
@@ -729,6 +886,7 @@ export default function Dateien({ toast, mobile }) {
</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">