diff --git a/backend/src/db.js b/backend/src/db.js index a288df7..8c0fe48 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -444,4 +444,27 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sni `); } +// ── Link-Liste Ordner + Erweiterungen ───────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_folders'").get()) { + db.exec(` + CREATE TABLE link_list_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📁', + sort_order INTEGER DEFAULT 0, + in_quickaccess INTEGER DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} +// link_list Spalten nachrüsten +{ + const cols = db.pragma('table_info(link_list)').map(c => c.name); + if (!cols.includes('folder_id')) db.exec('ALTER TABLE link_list ADD COLUMN folder_id INTEGER DEFAULT NULL'); + if (!cols.includes('sort_order')) db.exec('ALTER TABLE link_list ADD COLUMN sort_order INTEGER DEFAULT 0'); + if (!cols.includes('in_quickaccess')) db.exec('ALTER TABLE link_list ADD COLUMN in_quickaccess INTEGER DEFAULT 0'); +} + + module.exports = db; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 36c1e4d..cce2794 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -6,7 +6,12 @@ const uid = req => req.user.id; // ── Quick Links ─────────────────────────────────────────────────────────────── router.get('/links', authenticate, (req, res) => { - res.json(db.prepare('SELECT * FROM quick_links WHERE user_id=? ORDER BY sort_order,id').all(uid(req))); + const id = uid(req); + // Rückwärtskomp: bestehende quick_links + neue link_list-Einträge mit in_quickaccess=1 + Ordner mit in_quickaccess=1 + const legacy = db.prepare("SELECT *, 'quicklink' as item_type FROM quick_links WHERE user_id=? ORDER BY sort_order,id").all(id); + const listLinks = db.prepare("SELECT *, 'link_list' as item_type FROM link_list WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id); + const folders = db.prepare("SELECT *, 'folder' as item_type FROM link_list_folders WHERE user_id=? AND in_quickaccess=1 ORDER BY sort_order,id").all(id); + res.json([...legacy, ...listLinks, ...folders]); }); router.post('/links', authenticate, (req, res) => { const { title, url, icon = '🔗' } = req.body; diff --git a/backend/src/tools/linkliste/routes.js b/backend/src/tools/linkliste/routes.js index f0a353b..6658575 100644 --- a/backend/src/tools/linkliste/routes.js +++ b/backend/src/tools/linkliste/routes.js @@ -3,15 +3,71 @@ const db = require('../../db'); const { authenticate } = require('../../middleware/auth'); const router = express.Router(); +// ── Ordner ──────────────────────────────────────────────────────────────────── +router.get('/folders', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(req.user.id)); +}); + +router.post('/folders', authenticate, (req, res) => { + const uid = req.user.id; + const { name, icon='📁' } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM link_list_folders WHERE user_id=?').get(uid); + const r = db.prepare(`INSERT INTO link_list_folders (user_id,name,icon,sort_order,created_at) VALUES (?,?,?,?,datetime('now','localtime'))`) + .run(uid, name.trim(), icon, (max?.m ?? -1) + 1); + res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/folders/:id', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + const { name=f.name, icon=f.icon, in_quickaccess } = req.body; + const qa = in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : f.in_quickaccess; + db.prepare('UPDATE link_list_folders SET name=?, icon=?, in_quickaccess=? WHERE id=?').run(name, icon, qa, f.id); + res.json(db.prepare('SELECT * FROM link_list_folders WHERE id=?').get(f.id)); +}); + +router.delete('/folders/:id', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + // Links aus Ordner herauslösen + db.prepare('UPDATE link_list SET folder_id=NULL WHERE folder_id=? AND user_id=?').run(f.id, req.user.id); + db.prepare('DELETE FROM link_list_folders WHERE id=?').run(f.id); + res.json({ ok: true }); +}); + +// Ordner-Inhalt abrufen (für Folder-Modal im Dashboard) +router.get('/folders/:id/links', authenticate, (req, res) => { + const f = db.prepare('SELECT * FROM link_list_folders WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!f) return res.status(404).json({ error: 'Nicht gefunden' }); + const links = db.prepare('SELECT * FROM link_list WHERE folder_id=? AND user_id=? ORDER BY sort_order,id').all(f.id, req.user.id); + res.json({ folder: f, links }); +}); + +// ── Sortierung ──────────────────────────────────────────────────────────────── +router.put('/sort', authenticate, (req, res) => { + const uid = req.user.id; + const { links=[], folders=[] } = req.body; + const upLink = db.prepare('UPDATE link_list SET sort_order=? WHERE id=? AND user_id=?'); + const upFolder = db.prepare('UPDATE link_list_folders SET sort_order=? WHERE id=? AND user_id=?'); + db.transaction(() => { + links.forEach((id, i) => upLink.run(i, id, uid)); + folders.forEach((id, i) => upFolder.run(i, id, uid)); + })(); + res.json({ ok: true }); +}); + // ── Eigene Links ────────────────────────────────────────────────────────────── router.get('/', authenticate, (req, res) => { const uid = req.user.id; const own = db.prepare(` SELECT l.*, 0 as is_shared, NULL as owner_name, (SELECT COUNT(*) FROM link_list_shares WHERE link_id=l.id) as share_count - FROM link_list l WHERE l.user_id=? ORDER BY l.created_at DESC + FROM link_list l WHERE l.user_id=? ORDER BY l.folder_id IS NULL DESC, l.folder_id, l.sort_order, l.id `).all(uid); + const folders = db.prepare('SELECT * FROM link_list_folders WHERE user_id=? ORDER BY sort_order,id').all(uid); + const shared = db.prepare(` SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count FROM link_list l @@ -30,26 +86,35 @@ router.get('/', authenticate, (req, res) => { ORDER BY l.title ASC `).all(uid); - res.json({ own, shared, sharedByMe }); + res.json({ own, folders, shared, sharedByMe }); }); router.post('/', authenticate, (req, res) => { const uid = req.user.id; - const { title, url, icon = '🔗', description = '' } = req.body; + const { title, url, icon = '🔗', description = '', folder_id = null } = req.body; if (!title?.trim() || !url?.trim()) return res.status(400).json({ error: 'Titel und URL erforderlich' }); + const max = db.prepare('SELECT MAX(sort_order) m FROM link_list WHERE user_id=?').get(uid); const r = db.prepare(` - INSERT INTO link_list (user_id, title, url, icon, description, created_at) - VALUES (?,?,?,?,?,datetime('now','localtime')) - `).run(uid, title.trim(), url.trim(), icon, description.trim()); + INSERT INTO link_list (user_id, title, url, icon, description, folder_id, sort_order, created_at) + VALUES (?,?,?,?,?,?,?,datetime('now','localtime')) + `).run(uid, title.trim(), url.trim(), icon, description.trim(), folder_id || null, (max?.m ?? -1) + 1); res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(r.lastInsertRowid)); }); router.put('/:id', authenticate, (req, res) => { const link = db.prepare('SELECT * FROM link_list WHERE id=? AND user_id=?').get(req.params.id, req.user.id); if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); - const { title, url, icon, description } = req.body; - db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=? WHERE id=?') - .run(title??link.title, url??link.url, icon??link.icon, description??link.description, link.id); + const { title, url, icon, description, folder_id, in_quickaccess } = req.body; + db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=?, folder_id=?, in_quickaccess=? WHERE id=?') + .run( + title ?? link.title, + url ?? link.url, + icon ?? link.icon, + description ?? link.description, + folder_id !== undefined ? (folder_id || null) : link.folder_id, + in_quickaccess !== undefined ? (in_quickaccess ? 1 : 0) : link.in_quickaccess, + link.id + ); res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id)); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fcc1f24..5a0c1df 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
- {links.map(l => )} + {links.map(l => )}
); @@ -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 => )} + {links.slice(pi*pageSize, (pi+1)*pageSize).map(l => )} ))} @@ -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 && setImgOk(false)}/>} + {showCustomImg && setImgOk(false)}/>} + {showEmoji && {emoji}} + {l.title||l.name} + + ); + + 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 ; + } + return {content}; +} + +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 ( - - {showImg - ? setImgOk(false)}/> - : {emoji} - } - {l.title} - +
e.target===e.currentTarget&&onClose()}> +
+ {mob&&
} +
+
+ {folder.icon} {folder.name} +
+ +
+ {loading ?
+ : links.length===0 ?
Ordner ist leer
+ :
+ {links.map(l=>)} +
+ } +
+
); } 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 (
- + {folderModal && setFolderModal(null)}/>}
SCHNELLZUGRIFF
- - {/* Link-Grid */} - {links.length === 0 && !editMode - ?
Links können unter Einstellungen → Dashboard hinzugefügt werden.
+ {allItems.length === 0 + ?
+ Links oder Ordner in der Linkliste mit ● Schnellzugriff aktivieren. +
: mobile - ? + ? : } - - {/* Formular */} - {showForm && ( -
- {/* Icon-Picker */} -
-
ICON
-
- {CONST_ICONS.map(ic => ( - - ))} -
-
-
-
- - setF(f => ({...f, title:e.target.value}))} - style={{ ...S.inp, fontSize:16 }} placeholder="Mein Link" /> -
-
- - setF(f => ({...f, url:e.target.value}))} - onKeyDown={e => e.key==='Enter' && save()} - style={{ ...S.inp, fontSize:16 }} placeholder="https://…" inputMode="url" autoCapitalize="none" /> -
-
-
- - -
-
- )}
); } @@ -2312,7 +2271,10 @@ function AdminPanel({ toast, mobile, user }) { {section === 'dashboard' && (
- +
+ Schnellzugriff-Links werden direkt in der Linkliste verwaltet.
+ Dort den ● Schnellzugriff-Button bei einem Link oder Ordner aktivieren. +
diff --git a/frontend/src/tools/linkliste.jsx b/frontend/src/tools/linkliste.jsx index 18fcc1b..5398609 100644 --- a/frontend/src/tools/linkliste.jsx +++ b/frontend/src/tools/linkliste.jsx @@ -1,20 +1,23 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { api, S } from '../lib.js'; -const ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀']; +const LINK_ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀','🔐','📂','⭐','🔑','💻','🖥','📡']; +const FOLDER_ICONS = ['📁','📂','🗂','💼','🗃','📦','🏷','🔖','📋','📌','🗓','🖇']; function extIcon(url) { - try { - return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`; - } catch { return null; } + try { return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`; } + catch { return null; } } +function isMob() { return window.innerWidth < 768; } + +// ── Share Modal ─────────────────────────────────────────────────────────────── function ShareModal({ link, onClose, toast }) { const [username, setUsername] = useState(''); const [shares, setShares] = useState([]); const load = () => api(`/tools/linkliste/${link.id}/shares`).then(setShares).catch(()=>{}); useEffect(()=>{ load(); },[]); - const share = async () => { + const share = async () => { if (!username.trim()) return; try { await api(`/tools/linkliste/${link.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); } catch(e) { toast(e.message,'error'); } @@ -23,19 +26,17 @@ function ShareModal({ link, onClose, toast }) { try { await api(`/tools/linkliste/${link.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); } catch(e) { toast(e.message,'error'); } }; - const isMob = window.innerWidth < 768; + const mob = isMob(); return (
e.target===e.currentTarget&&onClose()}> -
- {isMob&&
} + {mob&&
}
-
- 🤝 {link.title} -
- +
🤝 {link.title}
+
setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()} @@ -54,8 +55,9 @@ function ShareModal({ link, onClose, toast }) { ); } -function LinkForm({ initial, onSave, onCancel, toast }) { - const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', ...initial }); +// ── Link Form ───────────────────────────────────────────────────────────────── +function LinkForm({ initial, folders, onSave, onCancel, toast }) { + const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', folder_id:null, ...initial }); const [busy, setBusy] = useState(false); const set = (k,v) => setForm(p=>({...p,[k]:v})); const save = async () => { @@ -68,29 +70,34 @@ function LinkForm({ initial, onSave, onCancel, toast }) { setBusy(false); }; return ( -
-
{initial?.id ? 'LINK BEARBEITEN' : 'NEUER LINK'}
+
+
{initial?.id?'LINK BEARBEITEN':'NEUER LINK'}
- +
ICON
- {ICONS.map(ic=>( + {LINK_ICONS.map(ic=>( + outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic} ))}
- set('title',e.target.value)} placeholder="Titel *" - style={{...S.inp,marginBottom:8}}/> - set('url',e.target.value)} placeholder="URL * (z.B. https://example.com)" - autoCapitalize="none" style={{...S.inp,marginBottom:8}}/> - set('description',e.target.value)} placeholder="Beschreibung (optional)" - style={{...S.inp,marginBottom:12,fontSize:13}}/> + set('title',e.target.value)} placeholder="Titel *" style={{...S.inp,marginBottom:8}}/> + set('url',e.target.value)} placeholder="URL *" autoCapitalize="none" style={{...S.inp,marginBottom:8}}/> + set('description',e.target.value)} placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:13}}/> + {folders.length>0 && ( +
+
IN ORDNER
+ +
+ )}
- @@ -99,62 +106,202 @@ function LinkForm({ initial, onSave, onCancel, toast }) { ); } +// ── Folder Form ─────────────────────────────────────────────────────────────── +function FolderForm({ initial, onSave, onCancel, toast }) { + const [form, setForm] = useState({ name:'', icon:'📁', ...initial }); + const [busy, setBusy] = useState(false); + const save = async () => { + if (!form.name.trim()) { toast('Name erforderlich','error'); return; } + setBusy(true); + try { await onSave(form); } + catch(e) { toast(e.message,'error'); } + setBusy(false); + }; + return ( +
+
{initial?.id?'ORDNER BEARBEITEN':'NEUER ORDNER'}
+
+
ICON
+
+ {FOLDER_ICONS.map(ic=>( + + ))} +
+
+ setForm(p=>({...p,name:e.target.value}))} placeholder="Ordner-Name *" + style={{...S.inp,marginBottom:12}}/> +
+ + +
+
+ ); +} + +// ── Main Component ──────────────────────────────────────────────────────────── export default function Linkliste({ toast, mobile }) { const [own, setOwn] = useState([]); + const [folders, setFolders] = useState([]); const [shared, setShared] = useState([]); const [byMe, setByMe] = 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 [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||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); }) + .then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); }) .catch(()=>{}); useEffect(()=>{ load(); },[]); + // ── CRUD Links ────────────────────────────────────────────────────────────── const addLink = async form => { const r = await api('/tools/linkliste',{body:form}); - setOwn(p=>[r,...p]); setShowForm(false); + setOwn(p=>[...p,r]); setShowForm(false); }; - const updateLink = async form => { const r = await api(`/tools/linkliste/${editItem.id}`,{method:'PUT',body:form}); setOwn(p=>p.map(l=>l.id===r.id?r:l)); setEditItem(null); }; - const deleteLink = async id => { await api(`/tools/linkliste/${id}`,{method:'DELETE'}); setOwn(p=>p.filter(l=>l.id!==id)); toast('Gelöscht'); }; - - const addToQuickLinks = async link => { - try { - await api('/dashboard/links',{body:{title:link.title,url:link.url,icon:link.icon}}); - toast(`„${link.title}" zu Schnellzugriffen hinzugefügt ✓`); - } catch(e) { toast(e.message,'error'); } + const toggleQA = async link => { + const val = link.in_quickaccess ? 0 : 1; + const r = await api(`/tools/linkliste/${link.id}`,{method:'PUT',body:{in_quickaccess:val}}); + setOwn(p=>p.map(l=>l.id===r.id?r:l)); + toast(val ? `„${link.title}" zum Schnellzugriff hinzugefügt ✓` : `„${link.title}" aus Schnellzugriff entfernt`); }; - const items = tab==='own' ? own : tab==='shared' ? shared : byMe; - const filtered = search.trim() - ? items.filter(l=>l.title.toLowerCase().includes(search.toLowerCase())||l.url.toLowerCase().includes(search.toLowerCase())||(l.description&&l.description.toLowerCase().includes(search.toLowerCase()))) - : items; + // ── CRUD Folders ──────────────────────────────────────────────────────────── + const addFolder = async form => { + const r = await api('/tools/linkliste/folders',{body:form}); + setFolders(p=>[...p,r]); setShowFolderForm(false); + }; + const updateFolder = async form => { + const r = await api(`/tools/linkliste/folders/${editFolder.id}`,{method:'PUT',body:form}); + setFolders(p=>p.map(f=>f.id===r.id?r:f)); setEditFolder(null); + }; + const deleteFolder = async id => { + await api(`/tools/linkliste/folders/${id}`,{method:'DELETE'}); + setFolders(p=>p.filter(f=>f.id!==id)); + // Links aus Ordner herauslösen (Backend macht das, Frontend nachführen) + setOwn(p=>p.map(l=>l.folder_id===id?{...l,folder_id:null}:l)); + toast('Ordner gelöscht'); + }; + const toggleFolderQA = async folder => { + const val = folder.in_quickaccess ? 0 : 1; + const r = await api(`/tools/linkliste/folders/${folder.id}`,{method:'PUT',body:{in_quickaccess:val}}); + setFolders(p=>p.map(f=>f.id===r.id?r:f)); + toast(val ? `Ordner „${folder.name}" zum Schnellzugriff hinzugefügt ✓` : `Ordner „${folder.name}" aus Schnellzugriff entfernt`); + }; - const LinkCard = ({ link, isShared=false, isByMe=false }) => { - const favicon = extIcon(link.url); + // ── Sortierung ────────────────────────────────────────────────────────────── + const moveLink = (id, dir) => { + setOwn(prev => { + const arr = [...prev]; + const idx = arr.findIndex(l=>l.id===id); + const nxt = idx+dir; + if (nxt<0||nxt>=arr.length) return prev; + [arr[idx],arr[nxt]] = [arr[nxt],arr[idx]]; + // Persist + api('/tools/linkliste/sort',{method:'PUT',body:{links:arr.map(l=>l.id)}}).catch(()=>{}); + return arr; + }); + }; + const moveFolder = (id, dir) => { + setFolders(prev => { + const arr = [...prev]; + const idx = arr.findIndex(f=>f.id===id); + const nxt = idx+dir; + if (nxt<0||nxt>=arr.length) return prev; + [arr[idx],arr[nxt]] = [arr[nxt],arr[idx]]; + api('/tools/linkliste/sort',{method:'PUT',body:{folders:arr.map(f=>f.id)}}).catch(()=>{}); + return arr; + }); + }; + + // ── Suche ─────────────────────────────────────────────────────────────────── + const q = search.trim().toLowerCase(); + const matchLink = l => + l.title.toLowerCase().includes(q) || + l.url.toLowerCase().includes(q) || + (l.description||'').toLowerCase().includes(q); + const matchFolder = f => f.name.toLowerCase().includes(q); + + // Für "Meine" Tab: flache Liste aller Links die passen + Ordner die passen + const filteredOwn = q ? own.filter(matchLink) : own; + const filteredFolders = q ? folders.filter(f => matchFolder(f) || own.some(l=>l.folder_id===f.id&&matchLink(l))) : folders; + const filteredShared = shared.filter(l=>!q||matchLink(l)); + const filteredByMe = byMe.filter(l=>!q||matchLink(l)); + + // ── Drag für Sortierung ───────────────────────────────────────────────────── + const onDragStart = (e,id) => { dragRef.current = id; e.dataTransfer.effectAllowed='move'; }; + const onDragOver = (e,id,type) => { + e.preventDefault(); + if (dragRef.current===id) return; + if (type==='link') { + const arr=[...own]; const from=arr.findIndex(l=>l.id===dragRef.current); const to=arr.findIndex(l=>l.id===id); + if (from<0||to<0) return; + arr.splice(to,0,arr.splice(from,1)[0]); setOwn(arr); + } + if (type==='folder') { + const arr=[...folders]; const from=arr.findIndex(f=>f.id===dragRef.current); const to=arr.findIndex(f=>f.id===id); + if (from<0||to<0) return; + arr.splice(to,0,arr.splice(from,1)[0]); setFolders(arr); + } + }; + const onDrop = (type) => { + if (type==='link') api('/tools/linkliste/sort',{method:'PUT',body:{links:own.map(l=>l.id)}}).catch(()=>{}); + if (type==='folder') api('/tools/linkliste/sort',{method:'PUT',body:{folders:folders.map(f=>f.id)}}).catch(()=>{}); + dragRef.current=null; + }; + + // ── Render ────────────────────────────────────────────────────────────────── + const QABadge = ({active}) => ( + + ); + + function LinkCard({ link, isShared=false, isByMe=false, inSort=false }) { + const [imgOk, setImgOk] = useState(true); + const favicon = (link.icon==='🔗'||!link.icon) ? extIcon(link.url) : null; + const showFavicon = !!favicon && imgOk; return ( -
+
onDragStart(e,link.id):undefined} + onDragOver={sortMode?e=>onDragOver(e,link.id,'link'):undefined} + onDrop={sortMode?()=>onDrop('link'):undefined}>
+ {/* Sort handle */} + {sortMode && !isShared && !isByMe && ( + + )} {/* Icon */} -
- {favicon - ? {e.target.style.display='none';e.target.nextSibling.style.display='flex';}} style={{width:20,height:20,objectFit:'contain'}} alt=""/> - : null} - {link.icon} + display:'flex',alignItems:'center',justifyContent:'center',fontSize:16}}> + {showFavicon + ? setImgOk(false)} style={{width:18,height:18,objectFit:'contain'}} alt=""/> + : {link.icon||'🔗'}}
{/* Info */}
@@ -167,96 +314,203 @@ export default function Linkliste({ toast, mobile }) { overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}> {link.url}
- {link.description && ( -
- {link.description} -
- )} - {isShared &&
von {link.owner_name}
} - {isByMe &&
→ {link.shared_with_name}
} + {link.description&&
{link.description}
} + {isShared&&
von {link.owner_name}
} + {isByMe&&
→ {link.shared_with_name}
}
-
- {/* Aktionen */} -
- - {!isShared && !isByMe && ( - <> - - - - + {/* Sort buttons */} + {sortMode && !isShared && !isByMe && ( +
+ + +
)}
+ {/* Aktionen */} + {!sortMode && ( +
+ {!isShared && !isByMe && ( + + )} + {!isShared && !isByMe && ( + <> + + + + + )} +
+ )}
); - }; + } + + function FolderSection({ folder }) { + const isOpen = !!expanded[folder.id]; + const folderLinks = filteredOwn.filter(l=>l.folder_id===folder.id); + return ( +
+
onDragStart(e,folder.id):undefined} + onDragOver={sortMode?e=>onDragOver(e,folder.id,'folder'):undefined} + onDrop={sortMode?()=>onDrop('folder'):undefined}> +
+ {sortMode && } + + {folder.icon} +
+ {folder.name} + + {folderLinks.length} Link{folderLinks.length!==1?'s':''} + +
+ {sortMode && ( +
+ + +
+ )} + {!sortMode && ( +
+ + + +
+ )} +
+
+ {isOpen && ( +
+ {folderLinks.length===0 + ?
+ Ordner ist leer – bearbeite einen Link und wähle diesen Ordner +
+ : folderLinks.map(link=>( + + )) + } + {!sortMode && ( + + )} +
+ )} +
+ ); + } + + const unfoldered = filteredOwn.filter(l=>!l.folder_id); return (
{shareItem && {setShareItem(null);load();}} toast={toast}/>} {/* Header */} -
+

Linkliste

-
- - {!showForm && !editItem && tab==='own' && ( - +
+ + {tab==='own' && ( + <> + + {!showFolderForm && !editFolder && !sortMode && ( + + )} + {!showForm && !editItem && !sortMode && ( + + )} + )}
- {/* Formular */} - {showForm && setShowForm(false)} toast={toast}/>} - {editItem && setEditItem(null)} toast={toast}/>} + {/* Formulare */} + {showFolderForm && !editFolder && setShowFolderForm(false)} toast={toast}/>} + {editFolder && setEditFolder(null)} toast={toast}/>} + {showForm && !editItem && setShowForm(false)} toast={toast}/>} + {editItem && setEditItem(null)} toast={toast}/>} {/* Tabs */}
- {[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['byMe','Geteilt von mir',byMe.length]].map(([k,l,cnt])=>( - + }}>{l}{cnt>0&&({cnt})} ))}
{/* Suche */}
- setSearch(e.target.value)} placeholder="Suchen…" + setSearch(e.target.value)} placeholder="Suchen in Links, Ordnern, URLs…" style={{...S.inp,paddingLeft:34}}/> - {search && }
- {/* Liste */} - {filtered.length===0 ? ( -
-
🔗
-
- {search?'Keine Treffer.':tab==='own'?'Noch keine Links – füge deinen ersten Link hinzu.':'Nichts geteilt.'} -
+ {/* Inhalt */} + {tab==='own' && ( +
+ {/* Ordner */} + {filteredFolders.map(folder=>)} + {/* Links ohne Ordner */} + {unfoldered.length>0 && filteredFolders.length>0 && ( +
OHNE ORDNER
+ )} + {unfoldered.map(link=>)} + {filteredFolders.length===0 && unfoldered.length===0 && ( +
+
🔗
+
+ {search?'Keine Treffer.':'Noch keine Links – füge deinen ersten Link oder Ordner hinzu.'} +
+
+ )}
- ) : filtered.map(link=>( - - ))} + )} + {tab==='shared' && ( + filteredShared.length===0 + ?
Nichts geteilt.
+ : filteredShared.map(link=>) + )} + {tab==='byMe' && ( + filteredByMe.length===0 + ?
Noch nichts geteilt.
+ : filteredByMe.map(link=>) + )}
); }