QR-Generator, Ordner-Sharing, Baustellenschild weg, Button-Fix

This commit is contained in:
2026-06-05 00:28:53 +02:00
parent 8ef217c49b
commit fa1eca6867
8 changed files with 624 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { S } from '../lib.js';
import { useState, useRef, useEffect, useCallback } from 'react';
import { S, api } from '../lib.js';
import QRCode from 'qrcode';
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const WEEKDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
@@ -2678,6 +2679,401 @@ function FarbRechner({ toast, mobile }) {
);
}
// ──────────────────────────────────────────────────────────── QR-Generator ──
const QR_SIZES = [128, 192, 256, 320, 512];
const DOT_STYLES = [
{ id:'square', label:'Quadrat' },
{ id:'rounded', label:'Abgerundet' },
{ id:'dots', label:'Punkte' },
{ id:'classy', label:'Elegant' },
];
const CORNER_STYLES = [
{ id:'square', label:'Quadrat' },
{ id:'rounded', label:'Abgerundet' },
{ id:'dot', label:'Punkt' },
];
const CAP_POS = [
{ id:'top', label:'Oben' },
{ id:'bottom', label:'Unten' },
{ id:'none', label:'Keine' },
];
function drawQrToCanvas(canvas, data, opts) {
const { size, fgColor, bgColor, margin, dotStyle, cornerStyle, caption, captionPos, captionColor, captionSize } = opts;
const capH = caption && captionPos !== 'none' ? (captionSize + 16) : 0;
const totalH = size + capH;
canvas.width = size;
canvas.height = totalH;
const ctx = canvas.getContext('2d');
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, size, totalH);
return QRCode.create(data, { errorCorrectionLevel: 'M' }).then(qr => {
const modules = qr.modules;
const n = modules.size;
const cellSize = (size - margin * 2) / n;
const qrTop = captionPos === 'top' ? capH : 0;
ctx.fillStyle = fgColor;
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
if (!modules.get(row, col)) continue;
const x = margin + col * cellSize;
const y = qrTop + margin + row * cellSize;
const w = cellSize;
// Corner squares (finder patterns): rows/cols 0-7 and last 7
const isCorner = (row < 7 && col < 7) || (row < 7 && col >= n-7) || (row >= n-7 && col < 7);
if (isCorner) {
if (cornerStyle === 'dot') {
const r = w * 0.5;
ctx.beginPath(); ctx.arc(x+r, y+r, r, 0, Math.PI*2); ctx.fill();
} else if (cornerStyle === 'rounded') {
const r = w * 0.35;
ctx.beginPath(); ctx.roundRect(x, y, w, w, r); ctx.fill();
} else {
ctx.fillRect(x, y, w, w);
}
} else {
if (dotStyle === 'dots') {
const r = w * 0.45;
ctx.beginPath(); ctx.arc(x+r, y+r, r, 0, Math.PI*2); ctx.fill();
} else if (dotStyle === 'rounded') {
const r = w * 0.3;
ctx.beginPath(); ctx.roundRect(x, y, w, w, r); ctx.fill();
} else if (dotStyle === 'classy') {
const r = w * 0.4;
ctx.beginPath(); ctx.roundRect(x+1, y+1, w-2, w-2, r); ctx.fill();
} else {
ctx.fillRect(x, y, w, w);
}
}
}
}
// Caption
if (caption && captionPos !== 'none') {
ctx.fillStyle = captionColor;
ctx.font = `${captionSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const cy = captionPos === 'top' ? capH / 2 : size + capH / 2;
ctx.fillText(caption, size / 2, cy, size - 16);
}
}).catch(() => {});
}
function QrGenerator({ toast, mobile }) {
const canvasRef = useRef(null);
const [tab, setTab] = useState('create');
const [saved, setSaved] = useState([]);
const [editing, setEditing] = useState(null); // saved qr being edited
// Form state
const DEFAULTS = {
label:'', url:'', size:256, fgColor:'#000000', bgColor:'#ffffff',
margin:4, dotStyle:'square', cornerStyle:'square',
caption:'', captionPos:'bottom', captionColor:'#000000', captionSize:14,
};
const [form, setForm] = useState(DEFAULTS);
const [busy, setBusy] = useState(false);
const set = (k, v) => setForm(p => ({...p, [k]: v}));
const loadSaved = () => api('/tools/qrcodes').then(setSaved).catch(()=>{});
useEffect(()=>{ loadSaved(); },[]);
// Re-render QR whenever form changes
useEffect(()=>{
if (!canvasRef.current || !form.url.trim()) return;
drawQrToCanvas(canvasRef.current, form.url, {
size:form.size, fgColor:form.fgColor, bgColor:form.bgColor,
margin:form.margin, dotStyle:form.dotStyle, cornerStyle:form.cornerStyle,
caption:form.caption, captionPos:form.captionPos,
captionColor:form.captionColor, captionSize:form.captionSize,
});
}, [form]);
const download = () => {
if (!canvasRef.current) return;
const a = document.createElement('a');
a.href = canvasRef.current.toDataURL('image/png');
a.download = `qrcode-${form.label||'export'}.png`;
a.click();
};
const save = async () => {
if (!form.url.trim()) { toast('URL erforderlich','error'); return; }
setBusy(true);
try {
const payload = { label:form.label, url:form.url, size:form.size,
fg_color:form.fgColor, bg_color:form.bgColor, margin:form.margin,
dot_style:form.dotStyle, corner_style:form.cornerStyle,
caption:form.caption, caption_pos:form.captionPos,
caption_color:form.captionColor, caption_size:form.captionSize };
if (editing) {
const r = await api(`/tools/qrcodes/${editing}`, {method:'PUT', body:payload});
setSaved(p=>p.map(q=>q.id===r.id?r:q));
setEditing(null);
toast('Gespeichert ✓');
} else {
const r = await api('/tools/qrcodes', {body:payload});
setSaved(p=>[r,...p]);
toast('QR-Code gespeichert ✓');
}
} catch(e) { toast(e.message,'error'); }
setBusy(false);
};
const deleteQr = async id => {
await api(`/tools/qrcodes/${id}`,{method:'DELETE'});
setSaved(p=>p.filter(q=>q.id!==id));
toast('Gelöscht');
};
const editQr = qr => {
setForm({ label:qr.label, url:qr.url, size:qr.size,
fgColor:qr.fg_color, bgColor:qr.bg_color, margin:qr.margin,
dotStyle:qr.dot_style, cornerStyle:qr.corner_style,
caption:qr.caption, captionPos:qr.caption_pos,
captionColor:qr.caption_color, captionSize:qr.caption_size });
setEditing(qr.id);
setTab('create');
};
const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'};
const lbl = {color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:3};
const tabs = [{id:'create',label:'✏️ Erstellen'},{id:'saved',label:`📋 Gespeichert (${saved.length})`}];
return (
<div>
<div style={{display:'flex',gap:6,marginBottom:18}}>
{tabs.map(t=>(
<button key={t.id} onClick={()=>setTab(t.id)} style={{
padding:'6px 16px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:tab===t.id?'#4ecdc4':'rgba(255,255,255,0.05)',
color:tab===t.id?'#0d0d0f':'rgba(255,255,255,0.65)',
border:tab===t.id?'none':'1px solid rgba(255,255,255,0.12)',
fontWeight:tab===t.id?700:400}}>{t.label}</button>
))}
{editing && <span style={{color:'#ffe66d',fontFamily:'monospace',fontSize:10,alignSelf:'center',marginLeft:4}}> Bearbeitung</span>}
</div>
{tab==='create' && (
<div style={{display:'grid',gridTemplateColumns:mobile?'1fr':'1fr 1fr',gap:16,alignItems:'start'}}>
{/* Linke Spalte: Einstellungen */}
<div>
<div style={{marginBottom:10}}>
<div style={lbl}>Name / Bezeichnung</div>
<input style={{...inp,width:'100%'}} placeholder="z.B. Meine Webseite" value={form.label} onChange={e=>set('label',e.target.value)}/>
</div>
<div style={{marginBottom:10}}>
<div style={lbl}>URL / Inhalt *</div>
<input style={{...inp,width:'100%'}} placeholder="https://example.com" autoCapitalize="none" value={form.url} onChange={e=>set('url',e.target.value)}/>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
<div>
<div style={lbl}>Größe (px)</div>
<select style={{...inp,width:'100%',cursor:'pointer'}} value={form.size} onChange={e=>set('size',parseInt(e.target.value))}>
{QR_SIZES.map(s=><option key={s} value={s}>{s}×{s}</option>)}
</select>
</div>
<div>
<div style={lbl}>Rand (Module)</div>
<input type="number" min={0} max={10} style={{...inp,width:'100%'}} value={form.margin} onChange={e=>set('margin',parseInt(e.target.value)||0)}/>
</div>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
<div>
<div style={lbl}>Punkte-Stil</div>
<select style={{...inp,width:'100%',cursor:'pointer'}} value={form.dotStyle} onChange={e=>set('dotStyle',e.target.value)}>
{DOT_STYLES.map(d=><option key={d.id} value={d.id}>{d.label}</option>)}
</select>
</div>
<div>
<div style={lbl}>Ecken-Stil</div>
<select style={{...inp,width:'100%',cursor:'pointer'}} value={form.cornerStyle} onChange={e=>set('cornerStyle',e.target.value)}>
{CORNER_STYLES.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</div>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
<div>
<div style={lbl}>Farbe (Punkte)</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<input type="color" value={form.fgColor} onChange={e=>set('fgColor',e.target.value)}
style={{width:38,height:34,borderRadius:6,border:'1px solid rgba(255,255,255,0.15)',cursor:'pointer',padding:2,background:'transparent'}}/>
<input style={{...inp,flex:1,fontSize:11}} value={form.fgColor} onChange={e=>set('fgColor',e.target.value)}/>
</div>
</div>
<div>
<div style={lbl}>Hintergrund</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<input type="color" value={form.bgColor} onChange={e=>set('bgColor',e.target.value)}
style={{width:38,height:34,borderRadius:6,border:'1px solid rgba(255,255,255,0.15)',cursor:'pointer',padding:2,background:'transparent'}}/>
<input style={{...inp,flex:1,fontSize:11}} value={form.bgColor} onChange={e=>set('bgColor',e.target.value)}/>
</div>
</div>
</div>
{/* Beschriftung */}
<div style={{padding:'10px 12px',borderRadius:8,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.07)',marginBottom:10}}>
<div style={{...lbl,marginBottom:8}}>BESCHRIFTUNG</div>
<div style={{marginBottom:8}}>
<div style={lbl}>Position</div>
<div style={{display:'flex',gap:5}}>
{CAP_POS.map(p=>(
<button key={p.id} onClick={()=>set('captionPos',p.id)} style={{
padding:'4px 12px',borderRadius:16,fontFamily:'monospace',fontSize:10,cursor:'pointer',
background:form.captionPos===p.id?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border:`1px solid ${form.captionPos===p.id?'#4ecdc4':'rgba(255,255,255,0.12)'}`,
color:form.captionPos===p.id?'#4ecdc4':'rgba(255,255,255,0.55)',fontWeight:form.captionPos===p.id?700:400}}>
{p.label}
</button>
))}
</div>
</div>
{form.captionPos !== 'none' && (
<>
<input style={{...inp,width:'100%',marginBottom:8,boxSizing:'border-box'}} placeholder="Text der Beschriftung"
value={form.caption} onChange={e=>set('caption',e.target.value)}/>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
<div>
<div style={lbl}>Farbe</div>
<div style={{display:'flex',gap:5,alignItems:'center'}}>
<input type="color" value={form.captionColor} onChange={e=>set('captionColor',e.target.value)}
style={{width:34,height:30,borderRadius:5,border:'1px solid rgba(255,255,255,0.15)',cursor:'pointer',padding:1,background:'transparent'}}/>
<input style={{...inp,flex:1,fontSize:10}} value={form.captionColor} onChange={e=>set('captionColor',e.target.value)}/>
</div>
</div>
<div>
<div style={lbl}>Schriftgröße</div>
<input type="number" min={8} max={36} style={{...inp,width:'100%'}}
value={form.captionSize} onChange={e=>set('captionSize',parseInt(e.target.value)||14)}/>
</div>
</div>
</>
)}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={download} disabled={!form.url.trim()}
style={{...S.btn('#ffe66d',true),flex:1,textAlign:'center',padding:'8px 0',opacity:form.url.trim()?1:0.4}}>
PNG laden
</button>
<button onClick={save} disabled={busy||!form.url.trim()}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'8px 0',opacity:form.url.trim()?1:0.4}}>
{busy?'…':editing?'✓ Aktualisieren':'💾 Speichern'}
</button>
{editing && <button onClick={()=>{setEditing(null);setForm(DEFAULTS);}}
style={{...S.btn('#ff6b9d',true),padding:'0 12px'}}></button>}
</div>
</div>
{/* Rechte Spalte: Vorschau */}
<div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:12}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>VORSCHAU</div>
{form.url.trim()
? <canvas ref={canvasRef} style={{borderRadius:8,maxWidth:'100%',
boxShadow:'0 4px 24px rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)'}}/>
: <div style={{width:200,height:200,borderRadius:8,background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)',display:'flex',alignItems:'center',justifyContent:'center'}}>
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:11}}>URL eingeben </span>
</div>
}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,textAlign:'center',lineHeight:1.6}}>
QR-Code wird lokal generiert<br/>Nichts wird ohne Speichern gespeichert
</div>
</div>
</div>
)}
{tab==='saved' && (
<div>
{saved.length===0
? <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,padding:'20px 0',textAlign:'center'}}>
Noch keine QR-Codes gespeichert
</div>
: <div style={{display:'flex',flexDirection:'column',gap:8}}>
{saved.map(qr=>(
<SavedQrCard key={qr.id} qr={qr} onEdit={editQr} onDelete={deleteQr} toast={toast}/>
))}
</div>
}
</div>
)}
</div>
);
}
function SavedQrCard({ qr, onEdit, onDelete, toast }) {
const canvasRef = useRef(null);
const [expanded, setExpanded] = useState(false);
useEffect(()=>{
if (!expanded || !canvasRef.current) return;
drawQrToCanvas(canvasRef.current, qr.url, {
size: Math.min(qr.size, 192), fgColor:qr.fg_color, bgColor:qr.bg_color,
margin:qr.margin, dotStyle:qr.dot_style, cornerStyle:qr.corner_style,
caption:qr.caption, captionPos:qr.caption_pos,
captionColor:qr.caption_color, captionSize:qr.caption_size,
});
},[expanded, qr]);
const download = () => {
if (!canvasRef.current) return;
// Re-draw in full size for download
const tmp = document.createElement('canvas');
drawQrToCanvas(tmp, qr.url, {
size:qr.size, fgColor:qr.fg_color, bgColor:qr.bg_color,
margin:qr.margin, dotStyle:qr.dot_style, cornerStyle:qr.corner_style,
caption:qr.caption, captionPos:qr.caption_pos,
captionColor:qr.caption_color, captionSize:qr.caption_size,
}).then(()=>{
const a = document.createElement('a');
a.href = tmp.toDataURL('image/png');
a.download = `qrcode-${qr.label||qr.id}.png`;
a.click();
});
};
return (
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.07)',overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 12px',cursor:'pointer'}}
onClick={()=>setExpanded(p=>!p)}>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11,width:14}}>{expanded?'▾':'▸'}</span>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{qr.label||'QR-Code'}
</div>
<div style={{color:'rgba(78,205,196,0.6)',fontFamily:'monospace',fontSize:10,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{qr.url}</div>
</div>
<div style={{display:'flex',gap:4}} onClick={e=>e.stopPropagation()}>
<button onClick={download} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}></button>
<button onClick={()=>onEdit(qr)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}></button>
<button onClick={()=>onDelete(qr.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 8px'}}></button>
</div>
</div>
{expanded && (
<div style={{padding:'0 12px 12px',display:'flex',gap:14,flexWrap:'wrap',alignItems:'flex-start'}}>
<canvas ref={canvasRef} style={{borderRadius:6,flexShrink:0,
boxShadow:'0 2px 12px rgba(0,0,0,0.3)',border:'1px solid rgba(255,255,255,0.08)',maxWidth:192}}/>
<div style={{fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.45)',lineHeight:2}}>
{[['Größe',`${qr.size}×${qr.size}px`],['Punkte',qr.dot_style],['Ecken',qr.corner_style],
['Rand',qr.margin],['Beschriftung',qr.caption||'—'],['Position',qr.caption_pos]].map(([k,v])=>(
<div key={k}><span style={{color:'rgba(255,255,255,0.25)'}}>{k}:</span> {v}</div>
))}
</div>
</div>
)}
</div>
);
}
const TOOL_DEFS = [
{id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true,
@@ -2690,7 +3086,7 @@ const TOOL_DEFS = [
{id:'netzwerk', label:'🌐 Netzwerk', desc:'IP, Subnetz, Geschwindigkeit, CIDR', ready:true, component:NetzwerkRechner},
{id:'datetime', label:'📅 Datetime', desc:'Zeiten umrechnen · Geburtstag · Differenz', ready:true, component:DatetimeRechner},
{id:'farben', label:'🎨 Farben', desc:'HEX, RGB, HSL, OKLCH umrechnen', ready:true, component:FarbRechner},
{id:'keygen', label:'🔐 Key-Gen', desc:'Sichere Keys & Secrets generieren', ready:true, component:KeyGen},
{id:'qrcode', label:'◻ QR-Code', desc:'QR-Codes erstellen & verwalten', ready:true, component:QrGenerator},
];
export default function DevTools({ toast, mobile }) {

View File

@@ -11,6 +11,55 @@ function extIcon(url) {
function isMob() { return window.innerWidth < 768; }
// ── Folder Share Modal ────────────────────────────────────────────────────────
function FolderShareModal({ folder, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/linkliste/folders/${folder.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/linkliste/folders/${folder.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/linkliste/folders/${folder.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const mob = isMob();
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{mob&&<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:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🤝 {folder.icon} {folder.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:10}}>
Ganzer Ordner inkl. aller Links wird geteilt
</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}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? 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)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
</div>
</div>
);
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function ShareModal({ link, onClose, toast }) {
const [username, setUsername] = useState('');
@@ -149,19 +198,22 @@ export default function Linkliste({ toast, mobile }) {
const [folders, setFolders] = useState([]);
const [shared, setShared] = useState([]);
const [byMe, setByMe] = useState([]);
const [sharedFolders, setSharedFolders] = useState([]);
const [sharedFoldersByMe, setSharedFoldersByMe] = useState([]);
const [tab, setTab] = useState('own');
const [showForm, setShowForm] = useState(false);
const [showFolderForm, setShowFolderForm] = useState(false);
const [editItem, setEditItem] = useState(null);
const [editFolder,setEditFolder]= useState(null);
const [shareItem, setShareItem] = useState(null);
const [shareFolderItem, setShareFolderItem] = useState(null);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState({}); // folder_id → bool
const [sortMode, setSortMode] = useState(false);
const dragRef = useRef(null);
const load = () => api('/tools/linkliste')
.then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); })
.then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); setSharedFolders(d.sharedFolders||[]); setSharedFoldersByMe(d.sharedFoldersByMe||[]); })
.catch(()=>{});
useEffect(()=>{ load(); },[]);
@@ -400,6 +452,7 @@ export default function Linkliste({ toast, mobile }) {
<QABadge active={!!folder.in_quickaccess}/> Schnellzugriff
</button>
<button onClick={()=>setEditFolder(folder)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}></button>
<button onClick={()=>setShareFolderItem(folder)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🤝</button>
<button onClick={()=>deleteFolder(folder.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}></button>
</div>
)}
@@ -433,6 +486,7 @@ export default function Linkliste({ toast, mobile }) {
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{shareFolderItem && <FolderShareModal folder={shareFolderItem} onClose={()=>{setShareFolderItem(null);load();}} toast={toast}/>}
{/* Header */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14,gap:8,flexWrap:'wrap'}}>
@@ -511,9 +565,33 @@ export default function Linkliste({ toast, mobile }) {
</div>
)}
{tab==='shared' && (
filteredShared.length===0
? <div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
: filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)
<div>
{/* Geteilte Ordner */}
{sharedFolders.map(folder=>(
<div key={`sf-${folder.id}`} style={{marginBottom:8}}>
<div style={{...S.card,padding:'10px 12px',borderRadius:'10px 10px 0 0',
borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<span style={{fontSize:18}}>{folder.icon}</span>
<div style={{flex:1}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginLeft:8}}>von {folder.owner_name}</span>
</div>
</div>
</div>
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
{(folder.links||[]).map(link=><LinkCard key={link.id} link={link} isShared/>)}
{(!folder.links||folder.links.length===0) && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px',textAlign:'center'}}>Ordner ist leer</div>}
</div>
</div>
))}
{/* Einzelne Links */}
{filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)}
{sharedFolders.length===0&&filteredShared.length===0 && (
<div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
)}
</div>
)}
{tab==='byMe' && (
filteredByMe.length===0