Linkliste: Ordner, Schnellzugriff-Toggle, Sortierung per Drag/Pfeile, Ordner-Modal im Dashboard
This commit is contained in:
@@ -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;
|
module.exports = db;
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ const uid = req => req.user.id;
|
|||||||
|
|
||||||
// ── Quick Links ───────────────────────────────────────────────────────────────
|
// ── Quick Links ───────────────────────────────────────────────────────────────
|
||||||
router.get('/links', authenticate, (req, res) => {
|
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) => {
|
router.post('/links', authenticate, (req, res) => {
|
||||||
const { title, url, icon = '🔗' } = req.body;
|
const { title, url, icon = '🔗' } = req.body;
|
||||||
|
|||||||
@@ -3,15 +3,71 @@ const db = require('../../db');
|
|||||||
const { authenticate } = require('../../middleware/auth');
|
const { authenticate } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
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 ──────────────────────────────────────────────────────────────
|
// ── Eigene Links ──────────────────────────────────────────────────────────────
|
||||||
router.get('/', authenticate, (req, res) => {
|
router.get('/', authenticate, (req, res) => {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
const own = db.prepare(`
|
const own = db.prepare(`
|
||||||
SELECT l.*, 0 as is_shared, NULL as owner_name,
|
SELECT l.*, 0 as is_shared, NULL as owner_name,
|
||||||
(SELECT COUNT(*) FROM link_list_shares WHERE link_id=l.id) as share_count
|
(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);
|
`).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(`
|
const shared = db.prepare(`
|
||||||
SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count
|
SELECT l.*, 1 as is_shared, u.username as owner_name, 0 as share_count
|
||||||
FROM link_list l
|
FROM link_list l
|
||||||
@@ -30,26 +86,35 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
ORDER BY l.title ASC
|
ORDER BY l.title ASC
|
||||||
`).all(uid);
|
`).all(uid);
|
||||||
|
|
||||||
res.json({ own, shared, sharedByMe });
|
res.json({ own, folders, shared, sharedByMe });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, (req, res) => {
|
router.post('/', authenticate, (req, res) => {
|
||||||
const uid = req.user.id;
|
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' });
|
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(`
|
const r = db.prepare(`
|
||||||
INSERT INTO link_list (user_id, title, url, icon, description, created_at)
|
INSERT INTO link_list (user_id, title, url, icon, description, folder_id, sort_order, created_at)
|
||||||
VALUES (?,?,?,?,?,datetime('now','localtime'))
|
VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))
|
||||||
`).run(uid, title.trim(), url.trim(), icon, description.trim());
|
`).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));
|
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(r.lastInsertRowid));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, (req, res) => {
|
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);
|
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' });
|
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
const { title, url, icon, description } = req.body;
|
const { title, url, icon, description, folder_id, in_quickaccess } = req.body;
|
||||||
db.prepare('UPDATE link_list SET title=?, url=?, icon=?, description=? WHERE id=?')
|
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, link.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));
|
res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0 }) {
|
|||||||
|
|
||||||
// ── Dashboard Widgets ─────────────────────────────────────────────────────────
|
// ── Dashboard Widgets ─────────────────────────────────────────────────────────
|
||||||
// ── QuickLinks Carousel (Mobile) ─────────────────────────────────────────────
|
// ── QuickLinks Carousel (Mobile) ─────────────────────────────────────────────
|
||||||
function QuickLinksCarousel({ links }) {
|
function QuickLinksCarousel({ links, onFolderClick }) {
|
||||||
const perRow = 4;
|
const perRow = 4;
|
||||||
const gap = 6;
|
const gap = 6;
|
||||||
const pageSize = perRow * 2;
|
const pageSize = perRow * 2;
|
||||||
@@ -413,7 +413,7 @@ function QuickLinksCarousel({ links }) {
|
|||||||
|
|
||||||
if (pageCount <= 1) return (
|
if (pageCount <= 1) return (
|
||||||
<div style={{ display:'grid', gridTemplateColumns:`repeat(${perRow}, 1fr)`, gap }}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ function QuickLinksCarousel({ links }) {
|
|||||||
gap, flexShrink:0, scrollSnapAlign:'start',
|
gap, flexShrink:0, scrollSnapAlign:'start',
|
||||||
width:'100%', minWidth:'100%', boxSizing:'border-box',
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -452,149 +452,108 @@ function QuickLinksCarousel({ links }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickLinkIcon({ l }) {
|
function QuickLinkIcon({ l, onFolderClick }) {
|
||||||
const [imgOk, setImgOk] = useState(true);
|
const [imgOk, setImgOk] = useState(true);
|
||||||
|
|
||||||
// Favicon nur wenn kein eigenes Icon gesetzt (Standard-🔗) oder bereits eine URL gespeichert ist
|
const hasCustomIcon = l.icon && l.icon !== '🔗';
|
||||||
const useDefaultFavicon = !l.icon || l.icon === '🔗';
|
const faviconUrl = (!hasCustomIcon && !l.isFolder) ? (() => {
|
||||||
const faviconUrl = (() => {
|
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(l.url).hostname}`; }
|
||||||
if (l.icon && l.icon.startsWith('http')) return l.icon;
|
catch { return null; }
|
||||||
if (useDefaultFavicon) {
|
})() : null;
|
||||||
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 showImg = !!faviconUrl && imgOk;
|
const showFavicon = !hasCustomIcon && !!faviconUrl && imgOk && !l.isFolder;
|
||||||
const showEmoji = !showImg;
|
const showCustomImg = hasCustomIcon && !l.isFolder && l.icon.startsWith('http');
|
||||||
const emoji = (l.icon && !l.icon.startsWith('http')) ? l.icon : '🔗';
|
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 (
|
return (
|
||||||
<a href={l.url} target="_blank" rel="noopener noreferrer"
|
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:6000,
|
||||||
style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:3,
|
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
|
||||||
padding:'4px 2px', background:'rgba(255,255,255,0.04)',
|
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||||
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
|
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:480,
|
||||||
textDecoration:'none', height:48, width:'100%', boxSizing:'border-box', cursor:'pointer' }}>
|
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||||
{showImg
|
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||||
? <img src={faviconUrl} alt="" style={{width:18,height:18,objectFit:'contain'}}
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
|
||||||
onError={() => setImgOk(false)}/>
|
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:15,fontWeight:700}}>
|
||||||
: <span style={{ fontSize:16 }}>{emoji}</span>
|
{folder.icon} {folder.name}
|
||||||
}
|
</div>
|
||||||
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace',
|
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',
|
||||||
maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>
|
cursor:'pointer',fontSize:20,padding:'0 4px'}}>✕</button>
|
||||||
</a>
|
</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 }) {
|
function QuickLinks({ toast, mobile }) {
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
|
||||||
const [links, setLinks] = useState([]);
|
const [links, setLinks] = useState([]);
|
||||||
const [editMode, setEdit] = useState(false);
|
const [folderModal, setFolderModal] = useState(null);
|
||||||
const [showForm, setForm] = useState(false);
|
|
||||||
const [form, setF] = useState({ title:'', url:'', icon:'🔗' });
|
|
||||||
const [editId, setEditId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
|
useEffect(() => { api('/dashboard/links').then(setLinks).catch(() => {}); }, []);
|
||||||
|
|
||||||
const getFavicon = url => {
|
// Links die kein Ordner sind
|
||||||
try { return `https://www.google.com/s2/favicons?sz=64&domain=${new URL(url).hostname}`; }
|
const regularLinks = links.filter(l => l.item_type !== 'folder');
|
||||||
catch { return null; }
|
// Ordner-Items
|
||||||
};
|
const folderItems = links.filter(l => l.item_type === 'folder');
|
||||||
|
// Alles zusammen für Carousel/Grid
|
||||||
const save = async () => {
|
const allItems = links.map(l => l.item_type === 'folder'
|
||||||
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich', 'error'); return; }
|
? { ...l, id: `folder-${l.id}`, isFolder: true, icon: l.icon||'📁', title: l.name }
|
||||||
let url = form.url.trim();
|
: l
|
||||||
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'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...S.card, marginBottom:10 }}>
|
<div style={{ ...S.card, marginBottom:10 }}>
|
||||||
<ConfirmDialog/>
|
{folderModal && <FolderModal folder={folderModal} onClose={()=>setFolderModal(null)}/>}
|
||||||
<div style={{ marginBottom:10 }}>
|
<div style={{ marginBottom:10 }}>
|
||||||
<div style={S.head}>SCHNELLZUGRIFF</div>
|
<div style={S.head}>SCHNELLZUGRIFF</div>
|
||||||
</div>
|
</div>
|
||||||
|
{allItems.length === 0
|
||||||
{/* Link-Grid */}
|
? <div style={{ color:'rgba(255,255,255,0.5)', fontSize:11, fontFamily:'monospace', padding:'6px 0' }}>
|
||||||
{links.length === 0 && !editMode
|
Links oder Ordner in der Linkliste mit ● Schnellzugriff aktivieren.
|
||||||
? <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>
|
</div>
|
||||||
: mobile
|
: mobile
|
||||||
? <QuickLinksCarousel links={links}/>
|
? <QuickLinksCarousel links={allItems} onFolderClick={setFolderModal}/>
|
||||||
: <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(58px,1fr))', gap:6 }}>
|
: <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill,minmax(58px,1fr))', gap:6 }}>
|
||||||
{links.map(l => (
|
{allItems.map(l => <QuickLinkIcon key={l.id} l={l} onFolderClick={l.isFolder?setFolderModal:undefined}/>)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2312,7 +2271,10 @@ function AdminPanel({ toast, mobile, user }) {
|
|||||||
{section === 'dashboard' && (
|
{section === 'dashboard' && (
|
||||||
<div>
|
<div>
|
||||||
<Sec title="SCHNELLZUGRIFF">
|
<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>
|
||||||
<Sec title="KALENDER (iCal)">
|
<Sec title="KALENDER (iCal)">
|
||||||
<CalendarSettings toast={toast}/>
|
<CalendarSettings toast={toast}/>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { api, S } from '../lib.js';
|
import { api, S } from '../lib.js';
|
||||||
|
|
||||||
const ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀'];
|
const LINK_ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀','🔐','📂','⭐','🔑','💻','🖥','📡'];
|
||||||
|
const FOLDER_ICONS = ['📁','📂','🗂','💼','🗃','📦','🏷','🔖','📋','📌','🗓','🖇'];
|
||||||
|
|
||||||
function extIcon(url) {
|
function extIcon(url) {
|
||||||
try {
|
try { return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`; }
|
||||||
return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`;
|
catch { return null; }
|
||||||
} catch { return null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMob() { return window.innerWidth < 768; }
|
||||||
|
|
||||||
|
// ── Share Modal ───────────────────────────────────────────────────────────────
|
||||||
function ShareModal({ link, onClose, toast }) {
|
function ShareModal({ link, onClose, toast }) {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [shares, setShares] = useState([]);
|
const [shares, setShares] = useState([]);
|
||||||
const load = () => api(`/tools/linkliste/${link.id}/shares`).then(setShares).catch(()=>{});
|
const load = () => api(`/tools/linkliste/${link.id}/shares`).then(setShares).catch(()=>{});
|
||||||
useEffect(()=>{ load(); },[]);
|
useEffect(()=>{ load(); },[]);
|
||||||
const share = async () => {
|
const share = async () => {
|
||||||
if (!username.trim()) return;
|
if (!username.trim()) return;
|
||||||
try { await api(`/tools/linkliste/${link.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
|
try { await api(`/tools/linkliste/${link.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
|
||||||
catch(e) { toast(e.message,'error'); }
|
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)); }
|
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'); }
|
catch(e) { toast(e.message,'error'); }
|
||||||
};
|
};
|
||||||
const isMob = window.innerWidth < 768;
|
const mob = isMob();
|
||||||
return (
|
return (
|
||||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
|
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
|
||||||
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
|
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
|
||||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||||
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
|
<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)'}}>
|
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||||
{isMob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
{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={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
|
||||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
|
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>🤝 {link.title}</div>
|
||||||
🤝 {link.title}
|
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||||
</div>
|
|
||||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px',flexShrink:0}}>✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
||||||
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
|
<input value={username} onChange={e=>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 }) {
|
// ── Link Form ─────────────────────────────────────────────────────────────────
|
||||||
const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', ...initial });
|
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 [busy, setBusy] = useState(false);
|
||||||
const set = (k,v) => setForm(p=>({...p,[k]:v}));
|
const set = (k,v) => setForm(p=>({...p,[k]:v}));
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -68,29 +70,34 @@ function LinkForm({ initial, onSave, onCancel, toast }) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={{...S.card, marginBottom:12}}>
|
<div style={{...S.card,marginBottom:12}}>
|
||||||
<div style={{...S.head,marginBottom:10}}>{initial?.id ? 'LINK BEARBEITEN' : 'NEUER LINK'}</div>
|
<div style={{...S.head,marginBottom:10}}>{initial?.id?'LINK BEARBEITEN':'NEUER LINK'}</div>
|
||||||
<div style={{marginBottom:8}}>
|
<div style={{marginBottom:8}}>
|
||||||
<label style={{...S.head,display:'block',marginBottom:4,fontSize:9}}>ICON</label>
|
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
|
||||||
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
|
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
|
||||||
{ICONS.map(ic=>(
|
{LINK_ICONS.map(ic=>(
|
||||||
<button key={ic} onClick={()=>set('icon',ic)} style={{
|
<button key={ic} onClick={()=>set('icon',ic)} style={{
|
||||||
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
|
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
|
||||||
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||||
outline:form.icon===ic?'1px solid #4ecdc4':'none',
|
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
|
||||||
}}>{ic}</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input value={form.title} onChange={e=>set('title',e.target.value)} placeholder="Titel *"
|
<input value={form.title} onChange={e=>set('title',e.target.value)} placeholder="Titel *" style={{...S.inp,marginBottom:8}}/>
|
||||||
style={{...S.inp,marginBottom:8}}/>
|
<input value={form.url} onChange={e=>set('url',e.target.value)} placeholder="URL *" autoCapitalize="none" style={{...S.inp,marginBottom:8}}/>
|
||||||
<input value={form.url} onChange={e=>set('url',e.target.value)} placeholder="URL * (z.B. https://example.com)"
|
<input value={form.description} onChange={e=>set('description',e.target.value)} placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:13}}/>
|
||||||
autoCapitalize="none" style={{...S.inp,marginBottom:8}}/>
|
{folders.length>0 && (
|
||||||
<input value={form.description} onChange={e=>set('description',e.target.value)} placeholder="Beschreibung (optional)"
|
<div style={{marginBottom:12}}>
|
||||||
style={{...S.inp,marginBottom:12,fontSize:13}}/>
|
<div style={{...S.head,fontSize:9,marginBottom:4}}>IN ORDNER</div>
|
||||||
|
<select value={form.folder_id||''} onChange={e=>set('folder_id',e.target.value?parseInt(e.target.value):null)}
|
||||||
|
style={{...S.inp,cursor:'pointer'}}>
|
||||||
|
<option value="">— Kein Ordner —</option>
|
||||||
|
{folders.map(f=><option key={f.id} value={f.id}>{f.icon} {f.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{display:'flex',gap:8}}>
|
<div style={{display:'flex',gap:8}}>
|
||||||
<button onClick={save} disabled={busy}
|
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
||||||
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
|
||||||
{busy?'…':'✓ Speichern'}
|
{busy?'…':'✓ Speichern'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
|
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
|
||||||
@@ -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 (
|
||||||
|
<div style={{...S.card,marginBottom:12}}>
|
||||||
|
<div style={{...S.head,marginBottom:10}}>{initial?.id?'ORDNER BEARBEITEN':'NEUER ORDNER'}</div>
|
||||||
|
<div style={{marginBottom:8}}>
|
||||||
|
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
|
||||||
|
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
|
||||||
|
{FOLDER_ICONS.map(ic=>(
|
||||||
|
<button key={ic} onClick={()=>setForm(p=>({...p,icon:ic}))} style={{
|
||||||
|
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
|
||||||
|
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
|
||||||
|
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input value={form.name} onChange={e=>setForm(p=>({...p,name:e.target.value}))} placeholder="Ordner-Name *"
|
||||||
|
style={{...S.inp,marginBottom:12}}/>
|
||||||
|
<div style={{display:'flex',gap:8}}>
|
||||||
|
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
||||||
|
{busy?'…':'✓ Speichern'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ────────────────────────────────────────────────────────────
|
||||||
export default function Linkliste({ toast, mobile }) {
|
export default function Linkliste({ toast, mobile }) {
|
||||||
const [own, setOwn] = useState([]);
|
const [own, setOwn] = useState([]);
|
||||||
|
const [folders, setFolders] = useState([]);
|
||||||
const [shared, setShared] = useState([]);
|
const [shared, setShared] = useState([]);
|
||||||
const [byMe, setByMe] = useState([]);
|
const [byMe, setByMe] = useState([]);
|
||||||
const [tab, setTab] = useState('own');
|
const [tab, setTab] = useState('own');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [showFolderForm, setShowFolderForm] = useState(false);
|
||||||
const [editItem, setEditItem] = useState(null);
|
const [editItem, setEditItem] = useState(null);
|
||||||
|
const [editFolder,setEditFolder]= useState(null);
|
||||||
const [shareItem, setShareItem] = useState(null);
|
const [shareItem, setShareItem] = useState(null);
|
||||||
const [search, setSearch] = useState('');
|
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')
|
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(()=>{});
|
.catch(()=>{});
|
||||||
|
|
||||||
useEffect(()=>{ load(); },[]);
|
useEffect(()=>{ load(); },[]);
|
||||||
|
|
||||||
|
// ── CRUD Links ──────────────────────────────────────────────────────────────
|
||||||
const addLink = async form => {
|
const addLink = async form => {
|
||||||
const r = await api('/tools/linkliste',{body: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 updateLink = async form => {
|
||||||
const r = await api(`/tools/linkliste/${editItem.id}`,{method:'PUT',body: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);
|
setOwn(p=>p.map(l=>l.id===r.id?r:l)); setEditItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteLink = async id => {
|
const deleteLink = async id => {
|
||||||
await api(`/tools/linkliste/${id}`,{method:'DELETE'});
|
await api(`/tools/linkliste/${id}`,{method:'DELETE'});
|
||||||
setOwn(p=>p.filter(l=>l.id!==id)); toast('Gelöscht');
|
setOwn(p=>p.filter(l=>l.id!==id)); toast('Gelöscht');
|
||||||
};
|
};
|
||||||
|
const toggleQA = async link => {
|
||||||
const addToQuickLinks = async link => {
|
const val = link.in_quickaccess ? 0 : 1;
|
||||||
try {
|
const r = await api(`/tools/linkliste/${link.id}`,{method:'PUT',body:{in_quickaccess:val}});
|
||||||
await api('/dashboard/links',{body:{title:link.title,url:link.url,icon:link.icon}});
|
setOwn(p=>p.map(l=>l.id===r.id?r:l));
|
||||||
toast(`„${link.title}" zu Schnellzugriffen hinzugefügt ✓`);
|
toast(val ? `„${link.title}" zum Schnellzugriff hinzugefügt ✓` : `„${link.title}" aus Schnellzugriff entfernt`);
|
||||||
} catch(e) { toast(e.message,'error'); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = tab==='own' ? own : tab==='shared' ? shared : byMe;
|
// ── CRUD Folders ────────────────────────────────────────────────────────────
|
||||||
const filtered = search.trim()
|
const addFolder = async form => {
|
||||||
? items.filter(l=>l.title.toLowerCase().includes(search.toLowerCase())||l.url.toLowerCase().includes(search.toLowerCase())||(l.description&&l.description.toLowerCase().includes(search.toLowerCase())))
|
const r = await api('/tools/linkliste/folders',{body:form});
|
||||||
: items;
|
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 }) => {
|
// ── Sortierung ──────────────────────────────────────────────────────────────
|
||||||
const favicon = extIcon(link.url);
|
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}) => (
|
||||||
|
<span style={{
|
||||||
|
display:'inline-block', width:7, height:7, borderRadius:'50%',
|
||||||
|
background:active?'#4ecdc4':'rgba(255,255,255,0.15)',
|
||||||
|
boxShadow:active?'0 0 5px #4ecdc4':'none',
|
||||||
|
marginRight:3, flexShrink:0,
|
||||||
|
}}/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div style={{...S.card,marginBottom:8,padding:'12px 14px'}}>
|
<div style={{...S.card,marginBottom:6,padding:'10px 12px'}}
|
||||||
|
draggable={sortMode&&!isShared&&!isByMe}
|
||||||
|
onDragStart={sortMode?e=>onDragStart(e,link.id):undefined}
|
||||||
|
onDragOver={sortMode?e=>onDragOver(e,link.id,'link'):undefined}
|
||||||
|
onDrop={sortMode?()=>onDrop('link'):undefined}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||||
|
{/* Sort handle */}
|
||||||
|
{sortMode && !isShared && !isByMe && (
|
||||||
|
<span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}>⠿</span>
|
||||||
|
)}
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div style={{width:36,height:36,borderRadius:9,flexShrink:0,overflow:'hidden',
|
<div style={{width:32,height:32,borderRadius:8,flexShrink:0,overflow:'hidden',
|
||||||
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.08)',
|
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.08)',
|
||||||
display:'flex',alignItems:'center',justifyContent:'center',fontSize:18}}>
|
display:'flex',alignItems:'center',justifyContent:'center',fontSize:16}}>
|
||||||
{favicon
|
{showFavicon
|
||||||
? <img src={favicon} onError={e=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} style={{width:20,height:20,objectFit:'contain'}} alt=""/>
|
? <img src={favicon} onError={()=>setImgOk(false)} style={{width:18,height:18,objectFit:'contain'}} alt=""/>
|
||||||
: null}
|
: <span>{link.icon||'🔗'}</span>}
|
||||||
<span style={{display:favicon?'none':'flex'}}>{link.icon}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div style={{flex:1,minWidth:0}}>
|
<div style={{flex:1,minWidth:0}}>
|
||||||
@@ -167,96 +314,203 @@ export default function Linkliste({ toast, mobile }) {
|
|||||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
|
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
|
||||||
{link.url}
|
{link.url}
|
||||||
</div>
|
</div>
|
||||||
{link.description && (
|
{link.description&&<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginTop:1}}>{link.description}</div>}
|
||||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
|
{isShared&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}>von {link.owner_name}</div>}
|
||||||
{link.description}
|
{isByMe&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}>→ {link.shared_with_name}</div>}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isShared && <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:2}}>von {link.owner_name}</div>}
|
|
||||||
{isByMe && <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:2}}>→ {link.shared_with_name}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Sort buttons */}
|
||||||
{/* Aktionen */}
|
{sortMode && !isShared && !isByMe && (
|
||||||
<div style={{display:'flex',gap:5,marginTop:10,flexWrap:'wrap'}}>
|
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
|
||||||
<button onClick={()=>addToQuickLinks(link)}
|
<button onClick={()=>moveLink(link.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||||
title="Zum Dashboard-Schnellzugriff hinzufügen"
|
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▲</button>
|
||||||
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'5px 10px'}}>
|
<button onClick={()=>moveLink(link.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||||
⊕ Schnellzugriff
|
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▼</button>
|
||||||
</button>
|
</div>
|
||||||
{!isShared && !isByMe && (
|
|
||||||
<>
|
|
||||||
<button onClick={()=>setShareItem(link)}
|
|
||||||
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'5px 10px'}}>
|
|
||||||
🤝 Teilen{link.share_count>0?` (${link.share_count})`:''}
|
|
||||||
</button>
|
|
||||||
<button onClick={()=>setEditItem(link)}
|
|
||||||
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'5px 10px'}}>✎</button>
|
|
||||||
<button onClick={()=>deleteLink(link.id)}
|
|
||||||
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'5px 10px'}}>✕</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Aktionen */}
|
||||||
|
{!sortMode && (
|
||||||
|
<div style={{display:'flex',gap:5,marginTop:8,flexWrap:'wrap'}}>
|
||||||
|
{!isShared && !isByMe && (
|
||||||
|
<button onClick={()=>toggleQA(link)}
|
||||||
|
style={{...S.btn(link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)',!!link.in_quickaccess),
|
||||||
|
fontSize:10,padding:'4px 10px',display:'flex',alignItems:'center',gap:4}}>
|
||||||
|
<QABadge active={!!link.in_quickaccess}/> Schnellzugriff
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isShared && !isByMe && (
|
||||||
|
<>
|
||||||
|
<button onClick={()=>setShareItem(link)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>
|
||||||
|
🤝{link.share_count>0?` (${link.share_count})`:''}
|
||||||
|
</button>
|
||||||
|
<button onClick={()=>setEditItem(link)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}>✎</button>
|
||||||
|
<button onClick={()=>deleteLink(link.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function FolderSection({ folder }) {
|
||||||
|
const isOpen = !!expanded[folder.id];
|
||||||
|
const folderLinks = filteredOwn.filter(l=>l.folder_id===folder.id);
|
||||||
|
return (
|
||||||
|
<div style={{marginBottom:8}}>
|
||||||
|
<div style={{...S.card,padding:'10px 12px',marginBottom:isOpen?0:0,
|
||||||
|
borderRadius:isOpen?'10px 10px 0 0':'10px',borderBottom:isOpen?'1px solid rgba(255,255,255,0.06)':undefined}}
|
||||||
|
draggable={sortMode}
|
||||||
|
onDragStart={sortMode?e=>onDragStart(e,folder.id):undefined}
|
||||||
|
onDragOver={sortMode?e=>onDragOver(e,folder.id,'folder'):undefined}
|
||||||
|
onDrop={sortMode?()=>onDrop('folder'):undefined}>
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||||
|
{sortMode && <span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}>⠿</span>}
|
||||||
|
<button onClick={()=>setExpanded(p=>({...p,[folder.id]:!isOpen}))}
|
||||||
|
style={{background:'none',border:'none',cursor:'pointer',color:'rgba(255,255,255,0.4)',
|
||||||
|
fontSize:12,padding:0,flexShrink:0,width:16}}>
|
||||||
|
{isOpen?'▾':'▸'}
|
||||||
|
</button>
|
||||||
|
<span style={{fontSize:18,flexShrink:0}}>{folder.icon}</span>
|
||||||
|
<div style={{flex:1,minWidth:0}}>
|
||||||
|
<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:10,marginLeft:8}}>
|
||||||
|
{folderLinks.length} Link{folderLinks.length!==1?'s':''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sortMode && (
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
|
||||||
|
<button onClick={()=>moveFolder(folder.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||||
|
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▲</button>
|
||||||
|
<button onClick={()=>moveFolder(folder.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
|
||||||
|
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}>▼</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!sortMode && (
|
||||||
|
<div style={{display:'flex',gap:5,flexShrink:0}}>
|
||||||
|
<button onClick={()=>toggleFolderQA(folder)}
|
||||||
|
style={{...S.btn(folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)',!!folder.in_quickaccess),
|
||||||
|
fontSize:10,padding:'4px 10px',display:'flex',alignItems:'center',gap:4}}>
|
||||||
|
<QABadge active={!!folder.in_quickaccess}/> Schnellzugriff
|
||||||
|
</button>
|
||||||
|
<button onClick={()=>setEditFolder(folder)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}>✎</button>
|
||||||
|
<button onClick={()=>deleteFolder(folder.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<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'}}>
|
||||||
|
{folderLinks.length===0
|
||||||
|
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px 4px',textAlign:'center'}}>
|
||||||
|
Ordner ist leer – bearbeite einen Link und wähle diesen Ordner
|
||||||
|
</div>
|
||||||
|
: folderLinks.map(link=>(
|
||||||
|
<LinkCard key={link.id} link={link}/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{!sortMode && (
|
||||||
|
<button onClick={()=>{ setShowForm(true); setShowFolderForm(false); setEditItem({folder_id:folder.id}); }}
|
||||||
|
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'5px 12px',width:'100%',textAlign:'center',marginBottom:4}}>
|
||||||
|
+ Link in diesem Ordner hinzufügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unfoldered = filteredOwn.filter(l=>!l.folder_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
|
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
|
||||||
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14,gap:8,flexWrap:'wrap'}}>
|
||||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Linkliste</h1>
|
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Linkliste</h1>
|
||||||
<div style={{display:'flex',gap:8}}>
|
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
||||||
<button onClick={load} title="Neu laden"
|
<button onClick={load} style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',
|
||||||
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,
|
borderRadius:8,color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}>↺</button>
|
||||||
color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}>↺</button>
|
{tab==='own' && (
|
||||||
{!showForm && !editItem && tab==='own' && (
|
<>
|
||||||
<button onClick={()=>setShowForm(true)}
|
<button onClick={()=>setSortMode(p=>!p)}
|
||||||
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Neuer Link</button>
|
style={{...S.btn(sortMode?'#ffe66d':'rgba(255,255,255,0.1)',!sortMode),fontSize:11,padding:'6px 12px'}}>
|
||||||
|
{sortMode?'✓ Fertig':'⇅ Sortieren'}
|
||||||
|
</button>
|
||||||
|
{!showFolderForm && !editFolder && !sortMode && (
|
||||||
|
<button onClick={()=>{setShowFolderForm(true);setShowForm(false);}}
|
||||||
|
style={{...S.btn('#ffe66d',true),fontSize:11,padding:'6px 12px'}}>+ Ordner</button>
|
||||||
|
)}
|
||||||
|
{!showForm && !editItem && !sortMode && (
|
||||||
|
<button onClick={()=>{setShowForm(true);setShowFolderForm(false);setEditItem(null);}}
|
||||||
|
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Link</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Formular */}
|
{/* Formulare */}
|
||||||
{showForm && <LinkForm onSave={addLink} onCancel={()=>setShowForm(false)} toast={toast}/>}
|
{showFolderForm && !editFolder && <FolderForm onSave={addFolder} onCancel={()=>setShowFolderForm(false)} toast={toast}/>}
|
||||||
{editItem && <LinkForm initial={editItem} onSave={updateLink} onCancel={()=>setEditItem(null)} toast={toast}/>}
|
{editFolder && <FolderForm initial={editFolder} onSave={updateFolder} onCancel={()=>setEditFolder(null)} toast={toast}/>}
|
||||||
|
{showForm && !editItem && <LinkForm folders={folders} onSave={addLink} onCancel={()=>setShowForm(false)} toast={toast}/>}
|
||||||
|
{editItem && <LinkForm initial={editItem} folders={folders} onSave={updateLink} onCancel={()=>setEditItem(null)} toast={toast}/>}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
||||||
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['byMe','Geteilt von mir',byMe.length]].map(([k,l,cnt])=>(
|
{[['own','Meine',own.length+folders.length],['shared','Geteilt mit mir',shared.length],['byMe','Geteilt von mir',byMe.length]].map(([k,l,cnt])=>(
|
||||||
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
|
<button key={k} onClick={()=>{setTab(k);setSearch('');setSortMode(false);}} style={{
|
||||||
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||||||
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
|
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
|
||||||
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
|
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
|
||||||
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
|
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
|
||||||
fontWeight:tab===k?700:400,
|
fontWeight:tab===k?700:400,
|
||||||
}}>
|
}}>{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}</button>
|
||||||
{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suche */}
|
{/* Suche */}
|
||||||
<div style={{position:'relative',marginBottom:12}}>
|
<div style={{position:'relative',marginBottom:12}}>
|
||||||
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',color:'rgba(255,255,255,0.5)',pointerEvents:'none'}}>⌕</span>
|
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',color:'rgba(255,255,255,0.5)',pointerEvents:'none'}}>⌕</span>
|
||||||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen…"
|
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen in Links, Ordnern, URLs…"
|
||||||
style={{...S.inp,paddingLeft:34}}/>
|
style={{...S.inp,paddingLeft:34}}/>
|
||||||
{search && <button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',transform:'translateY(-50%)',
|
{search&&<button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',transform:'translateY(-50%)',
|
||||||
background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:16}}>✕</button>}
|
background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:16}}>✕</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Inhalt */}
|
||||||
{filtered.length===0 ? (
|
{tab==='own' && (
|
||||||
<div style={{...S.card,textAlign:'center',padding:40}}>
|
<div>
|
||||||
<div style={{fontSize:28,marginBottom:10}}>🔗</div>
|
{/* Ordner */}
|
||||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
|
{filteredFolders.map(folder=><FolderSection key={folder.id} folder={folder}/>)}
|
||||||
{search?'Keine Treffer.':tab==='own'?'Noch keine Links – füge deinen ersten Link hinzu.':'Nichts geteilt.'}
|
{/* Links ohne Ordner */}
|
||||||
</div>
|
{unfoldered.length>0 && filteredFolders.length>0 && (
|
||||||
|
<div style={{...S.head,fontSize:9,margin:'12px 0 6px',color:'rgba(255,255,255,0.3)'}}>OHNE ORDNER</div>
|
||||||
|
)}
|
||||||
|
{unfoldered.map(link=><LinkCard key={link.id} link={link}/>)}
|
||||||
|
{filteredFolders.length===0 && unfoldered.length===0 && (
|
||||||
|
<div style={{...S.card,textAlign:'center',padding:40}}>
|
||||||
|
<div style={{fontSize:28,marginBottom:10}}>🔗</div>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
|
||||||
|
{search?'Keine Treffer.':'Noch keine Links – füge deinen ersten Link oder Ordner hinzu.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.map(link=>(
|
)}
|
||||||
<LinkCard key={link.id} link={link} isShared={tab==='shared'} isByMe={tab==='byMe'}/>
|
{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/>)
|
||||||
|
)}
|
||||||
|
{tab==='byMe' && (
|
||||||
|
filteredByMe.length===0
|
||||||
|
? <div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Noch nichts geteilt.</div></div>
|
||||||
|
: filteredByMe.map(link=><LinkCard key={link.id} link={link} isByMe/>)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user