Linkliste: Ordner, Schnellzugriff-Toggle, Sortierung per Drag/Pfeile, Ordner-Modal im Dashboard
This commit is contained in:
@@ -389,7 +389,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0 }) {
|
||||
|
||||
// ── Dashboard Widgets ─────────────────────────────────────────────────────────
|
||||
// ── QuickLinks Carousel (Mobile) ─────────────────────────────────────────────
|
||||
function QuickLinksCarousel({ links }) {
|
||||
function QuickLinksCarousel({ links, onFolderClick }) {
|
||||
const perRow = 4;
|
||||
const gap = 6;
|
||||
const pageSize = perRow * 2;
|
||||
@@ -413,7 +413,7 @@ function QuickLinksCarousel({ links }) {
|
||||
|
||||
if (pageCount <= 1) return (
|
||||
<div style={{ display:'grid', gridTemplateColumns:`repeat(${perRow}, 1fr)`, gap }}>
|
||||
{links.map(l => <QuickLinkIcon key={l.id} l={l}/>)}
|
||||
{links.map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={onFolderClick}/>)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -431,7 +431,7 @@ function QuickLinksCarousel({ links }) {
|
||||
gap, flexShrink:0, scrollSnapAlign:'start',
|
||||
width:'100%', minWidth:'100%', boxSizing:'border-box',
|
||||
}}>
|
||||
{links.slice(pi*pageSize, (pi+1)*pageSize).map(l => <QuickLinkIcon key={l.id} l={l}/>)}
|
||||
{links.slice(pi*pageSize, (pi+1)*pageSize).map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={onFolderClick}/>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -452,149 +452,108 @@ function QuickLinksCarousel({ links }) {
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinkIcon({ l }) {
|
||||
function QuickLinkIcon({ l, onFolderClick }) {
|
||||
const [imgOk, setImgOk] = useState(true);
|
||||
|
||||
// Favicon nur wenn kein eigenes Icon gesetzt (Standard-🔗) oder bereits eine URL gespeichert ist
|
||||
const useDefaultFavicon = !l.icon || l.icon === '🔗';
|
||||
const faviconUrl = (() => {
|
||||
if (l.icon && l.icon.startsWith('http')) return l.icon;
|
||||
if (useDefaultFavicon) {
|
||||
try {
|
||||
const host = new URL(l.url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`;
|
||||
} catch { return null; }
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const hasCustomIcon = l.icon && l.icon !== '🔗';
|
||||
const faviconUrl = (!hasCustomIcon && !l.isFolder) ? (() => {
|
||||
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(l.url).hostname}`; }
|
||||
catch { return null; }
|
||||
})() : null;
|
||||
|
||||
const showImg = !!faviconUrl && imgOk;
|
||||
const showEmoji = !showImg;
|
||||
const emoji = (l.icon && !l.icon.startsWith('http')) ? l.icon : '🔗';
|
||||
const showFavicon = !hasCustomIcon && !!faviconUrl && imgOk && !l.isFolder;
|
||||
const showCustomImg = hasCustomIcon && !l.isFolder && l.icon.startsWith('http');
|
||||
const showEmoji = !showFavicon && !showCustomImg;
|
||||
const emoji = l.isFolder ? (l.icon||'📁') : (hasCustomIcon ? l.icon : '🔗');
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{showFavicon && <img src={faviconUrl} alt="" style={{width:18,height:18,objectFit:'contain'}} onError={()=>setImgOk(false)}/>}
|
||||
{showCustomImg && <img src={l.icon} alt="" style={{width:18,height:18,objectFit:'contain'}} onError={()=>setImgOk(false)}/>}
|
||||
{showEmoji && <span style={{fontSize:18}}>{emoji}</span>}
|
||||
<span style={{color:'rgba(255,255,255,0.65)',fontSize:8,fontFamily:'monospace',
|
||||
maxWidth:52,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.title||l.name}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const baseStyle = {display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:3,
|
||||
padding:'4px 2px',background:'rgba(255,255,255,0.04)',
|
||||
border: l.isFolder ? '1px solid rgba(78,205,196,0.25)' : '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius:10,textDecoration:'none',height:48,width:'100%',boxSizing:'border-box',cursor:'pointer'};
|
||||
|
||||
if (l.isFolder && onFolderClick) {
|
||||
const realId = typeof l.id==='string'&&l.id.startsWith('folder-') ? parseInt(l.id.replace('folder-','')) : l.id;
|
||||
return <button onClick={()=>onFolderClick({...l,id:realId})} style={{...baseStyle,background:'rgba(78,205,196,0.04)'}}>{content}</button>;
|
||||
}
|
||||
return <a href={l.url} target="_blank" rel="noopener noreferrer" style={baseStyle}>{content}</a>;
|
||||
}
|
||||
|
||||
function FolderModal({ folder, onClose }) {
|
||||
const [links, setLinks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(()=>{
|
||||
api(`/tools/linkliste/folders/${folder.id}/links`)
|
||||
.then(d=>{ setLinks(d.links||[]); setLoading(false); })
|
||||
.catch(()=>setLoading(false));
|
||||
},[folder.id]);
|
||||
const mob = window.innerWidth < 768;
|
||||
return (
|
||||
<a href={l.url} target="_blank" rel="noopener noreferrer"
|
||||
style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:3,
|
||||
padding:'4px 2px', background:'rgba(255,255,255,0.04)',
|
||||
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
|
||||
textDecoration:'none', height:48, width:'100%', boxSizing:'border-box', cursor:'pointer' }}>
|
||||
{showImg
|
||||
? <img src={faviconUrl} alt="" style={{width:18,height:18,objectFit:'contain'}}
|
||||
onError={() => setImgOk(false)}/>
|
||||
: <span style={{ fontSize:16 }}>{emoji}</span>
|
||||
}
|
||||
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace',
|
||||
maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>
|
||||
</a>
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',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:480,
|
||||
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:16}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:15,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:20,padding:'0 4px'}}>✕</button>
|
||||
</div>
|
||||
{loading ? <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,textAlign:'center',padding:20}}>…</div>
|
||||
: links.length===0 ? <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,textAlign:'center',padding:20}}>Ordner ist leer</div>
|
||||
: <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(72px,1fr))',gap:8}}>
|
||||
{links.map(l=><QuickLinkIcon key={l.id} l={l}/>)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinks({ toast, mobile }) {
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [links, setLinks] = useState([]);
|
||||
const [editMode, setEdit] = useState(false);
|
||||
const [showForm, setForm] = useState(false);
|
||||
const [form, setF] = useState({ title:'', url:'', icon:'🔗' });
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [folderModal, setFolderModal] = useState(null);
|
||||
|
||||
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
|
||||
|
||||
const getFavicon = url => {
|
||||
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
|
||||
let url = form.url.trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
// Auto favicon if user left default
|
||||
const icon = form.icon === '🔗' ? (getFavicon(url) || '🔗') : form.icon;
|
||||
try {
|
||||
if (editId) {
|
||||
const u = await api(`/dashboard/links/${editId}`, { method:'PUT', body:{...form, icon, url} });
|
||||
setLinks(p => p.map(l => l.id===editId ? u : l));
|
||||
} else {
|
||||
const n = await api('/dashboard/links', { body:{...form, icon, url} });
|
||||
setLinks(p => [...p, n]);
|
||||
}
|
||||
toast(editId ? 'Aktualisiert' : 'Gespeichert');
|
||||
setF({ title:'', url:'', icon:'🔗' }); setForm(false); setEditId(null);
|
||||
} catch(e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const del = async id => {
|
||||
if (!await confirm('Link wirklich löschen?')) return;
|
||||
try { await api(`/dashboard/links/${id}`, { method:'DELETE' }); setLinks(p => p.filter(l => l.id!==id)); toast('Gelöscht'); }
|
||||
catch(e) { toast(e.message, 'error'); }
|
||||
};
|
||||
// Links die kein Ordner sind
|
||||
const regularLinks = links.filter(l => l.item_type !== 'folder');
|
||||
// Ordner-Items
|
||||
const folderItems = links.filter(l => l.item_type === 'folder');
|
||||
// Alles zusammen für Carousel/Grid
|
||||
const allItems = links.map(l => l.item_type === 'folder'
|
||||
? { ...l, id: `folder-${l.id}`, isFolder: true, icon: l.icon||'📁', title: l.name }
|
||||
: l
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ ...S.card, marginBottom:10 }}>
|
||||
<ConfirmDialog/>
|
||||
{folderModal && <FolderModal folder={folderModal} onClose={()=>setFolderModal(null)}/>}
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<div style={S.head}>SCHNELLZUGRIFF</div>
|
||||
</div>
|
||||
|
||||
{/* Link-Grid */}
|
||||
{links.length === 0 && !editMode
|
||||
? <div style={{ color:'rgba(255,255,255,0.5)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>Links können unter Einstellungen → Dashboard hinzugefügt werden.</div>
|
||||
{allItems.length === 0
|
||||
? <div style={{ color:'rgba(255,255,255,0.5)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>
|
||||
Links oder Ordner in der Linkliste mit ● Schnellzugriff aktivieren.
|
||||
</div>
|
||||
: mobile
|
||||
? <QuickLinksCarousel links={links}/>
|
||||
? <QuickLinksCarousel links={allItems} onFolderClick={setFolderModal}/>
|
||||
: <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(58px,1fr))', gap:6 }}>
|
||||
{links.map(l => (
|
||||
<div key={l.id} style={{ position:'relative' }}>
|
||||
<a href={l.url} target="_blank" rel="noopener noreferrer"
|
||||
style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:3,
|
||||
padding:'6px 4px', background:'rgba(255,255,255,0.04)',
|
||||
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
|
||||
textDecoration:'none', height:58, cursor:'pointer' }}>
|
||||
{l.icon && l.icon.startsWith('http')
|
||||
? <img src={l.icon} alt="" style={{width:20,height:20,objectFit:'contain'}} onError={e=>e.target.style.display='none'}/>
|
||||
: <span style={{ fontSize:18 }}>{l.icon}</span>}
|
||||
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace',
|
||||
maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
{allItems.map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={l.isFolder?setFolderModal:undefined}/>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* Formular */}
|
||||
{showForm && (
|
||||
<div style={{ marginTop:12, padding:12, background:'rgba(0,0,0,0.25)', border:'1px solid rgba(255,255,255,0.06)', borderRadius:10 }}>
|
||||
{/* Icon-Picker */}
|
||||
<div style={{ marginBottom:10 }}>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
|
||||
{CONST_ICONS.map(ic => (
|
||||
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
|
||||
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
|
||||
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
|
||||
background: form.icon===ic ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
|
||||
}}>{ic}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginBottom:10 }}>
|
||||
<div>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:3 }}>TITEL</label>
|
||||
<input value={form.title} onChange={e => setF(f => ({...f, title:e.target.value}))}
|
||||
style={{ ...S.inp, fontSize:16 }} placeholder="Mein Link" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...S.head, display:'block', marginBottom:3 }}>URL</label>
|
||||
<input value={form.url} onChange={e => setF(f => ({...f, url:e.target.value}))}
|
||||
onKeyDown={e => e.key==='Enter' && save()}
|
||||
style={{ ...S.inp, fontSize:16 }} placeholder="https://…" inputMode="url" autoCapitalize="none" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<button onClick={save} style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center' }}>{editId ? '✓ Aktualisieren' : '✓ Speichern'}</button>
|
||||
<button onClick={() => { setForm(false); setEditId(null); setF({ title:'', url:'', icon:'🔗' }); }} style={{ ...S.btn('#ff6b9d'), flex:1, textAlign:'center' }}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2312,7 +2271,10 @@ function AdminPanel({ toast, mobile, user }) {
|
||||
{section === 'dashboard' && (
|
||||
<div>
|
||||
<Sec title="SCHNELLZUGRIFF">
|
||||
<QuickLinksSettings toast={toast}/>
|
||||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,lineHeight:1.7}}>
|
||||
Schnellzugriff-Links werden direkt in der <strong style={{color:'#4ecdc4'}}>Linkliste</strong> verwaltet.<br/>
|
||||
Dort den ● Schnellzugriff-Button bei einem Link oder Ordner aktivieren.
|
||||
</div>
|
||||
</Sec>
|
||||
<Sec title="KALENDER (iCal)">
|
||||
<CalendarSettings toast={toast}/>
|
||||
|
||||
Reference in New Issue
Block a user