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)}/>}
-
- {/* 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
- ?
+ ?
:
- {links.map(l => (
-
- ))}
+ {allItems.map(l =>
)}
}
-
- {/* Formular */}
- {showForm && (
-
- {/* Icon-Picker */}
-
-
ICON
-
- {CONST_ICONS.map(ic => (
-
- ))}
-
-
-
-
-
-
-
-
- )}
);
}
@@ -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
+
+
+ )}
-