diff --git a/backend/src/db.js b/backend/src/db.js
index 23ad77c..dfea999 100644
--- a/backend/src/db.js
+++ b/backend/src/db.js
@@ -279,6 +279,33 @@ for (const [k, v] of loginDefaults) {
db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v);
}
+// ── Link-Liste ────────────────────────────────────────────────────────────────
+if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
+ db.exec(`
+ CREATE TABLE link_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ url TEXT NOT NULL,
+ icon TEXT NOT NULL DEFAULT '🔗',
+ description TEXT NOT NULL DEFAULT '',
+ created_at DATETIME DEFAULT NULL
+ )
+ `);
+}
+if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list_shares'").get()) {
+ db.exec(`
+ CREATE TABLE link_list_shares (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ link_id INTEGER NOT NULL REFERENCES link_list(id) ON DELETE CASCADE,
+ shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at DATETIME DEFAULT NULL,
+ UNIQUE(link_id, shared_with)
+ )
+ `);
+}
+
// ── Kalkulator-Shares ─────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) {
db.exec(`
diff --git a/backend/src/tools/linkliste/routes.js b/backend/src/tools/linkliste/routes.js
new file mode 100644
index 0000000..f0a353b
--- /dev/null
+++ b/backend/src/tools/linkliste/routes.js
@@ -0,0 +1,91 @@
+const express = require('express');
+const db = require('../../db');
+const { authenticate } = require('../../middleware/auth');
+const router = express.Router();
+
+// ── 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
+ `).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
+ JOIN link_list_shares s ON s.link_id=l.id
+ JOIN users u ON u.id=l.user_id
+ WHERE s.shared_with=?
+ ORDER BY l.created_at DESC
+ `).all(uid);
+
+ const sharedByMe = db.prepare(`
+ SELECT l.*, 0 as is_shared, u.username as shared_with_name
+ FROM link_list l
+ JOIN link_list_shares s ON s.link_id=l.id
+ JOIN users u ON u.id=s.shared_with
+ WHERE l.user_id=?
+ ORDER BY l.title ASC
+ `).all(uid);
+
+ res.json({ own, shared, sharedByMe });
+});
+
+router.post('/', authenticate, (req, res) => {
+ const uid = req.user.id;
+ const { title, url, icon = '🔗', description = '' } = req.body;
+ if (!title?.trim() || !url?.trim()) return res.status(400).json({ error: 'Titel und URL erforderlich' });
+ 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());
+ 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);
+ res.json(db.prepare('SELECT * FROM link_list WHERE id=?').get(link.id));
+});
+
+router.delete('/:id', authenticate, (req, res) => {
+ const r = db.prepare('DELETE FROM link_list WHERE id=? AND user_id=?').run(req.params.id, req.user.id);
+ if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
+ res.json({ ok: true });
+});
+
+// ── Shares ────────────────────────────────────────────────────────────────────
+router.get('/:id/shares', 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' });
+ res.json(db.prepare(`
+ SELECT u.id, u.username, s.created_at as shared_at
+ FROM link_list_shares s JOIN users u ON u.id=s.shared_with
+ WHERE s.link_id=?
+ `).all(link.id));
+});
+
+router.post('/:id/share', 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 target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
+ if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
+ if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
+ db.prepare(`INSERT OR IGNORE INTO link_list_shares (link_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`)
+ .run(link.id, req.user.id, target.id);
+ res.json({ ok: true });
+});
+
+router.delete('/:id/share/:userId', 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' });
+ db.prepare('DELETE FROM link_list_shares WHERE link_id=? AND shared_with=?').run(link.id, req.params.userId);
+ res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx
index 60a114c..04216af 100644
--- a/frontend/src/icons.jsx
+++ b/frontend/src/icons.jsx
@@ -131,14 +131,13 @@ export const DatabaseIcon = ({size=20,color='currentColor',sw}) => base(<>
>, size, color, sw);
-export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<>
-
-
->, size, color, sw);
-
export const ZapIcon = ({size=20,color='currentColor',sw}) => base(<>
>, size, color, sw);
export const MessageIcon = ({size=20,color='currentColor',sw}) => base(<>
>, size, color, sw);
+export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<>
+
+
+>, size, color, sw);
diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js
index d80209d..0c09386 100644
--- a/frontend/src/toolRegistry.js
+++ b/frontend/src/toolRegistry.js
@@ -2,7 +2,8 @@ import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx';
import Bestellungen from './tools/bestellungen.jsx';
import Dateien from './tools/dateien.jsx';
import Nachrichten from './tools/nachrichten.jsx';
-import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon } from './icons.jsx';
+import Linkliste from './tools/linkliste.jsx';
+import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon } from './icons.jsx';
export const TOOLS = [
{
@@ -45,6 +46,14 @@ export const TOOLS = [
group: 'Werkzeuge',
component: Nachrichten,
},
+ {
+ id: 'linkliste',
+ Icon: LinkIcon,
+ label: 'Linkliste',
+ navLabel: 'Linkliste',
+ group: 'Werkzeuge',
+ component: Linkliste,
+ },
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
];
diff --git a/frontend/src/tools/linkliste.jsx b/frontend/src/tools/linkliste.jsx
new file mode 100644
index 0000000..18fcc1b
--- /dev/null
+++ b/frontend/src/tools/linkliste.jsx
@@ -0,0 +1,262 @@
+import { useState, useEffect } from 'react';
+import { api, S } from '../lib.js';
+
+const ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀'];
+
+function extIcon(url) {
+ try {
+ return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`;
+ } catch { return null; }
+}
+
+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 () => {
+ 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'); }
+ };
+ const unshare = async 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'); }
+ };
+ const isMob = window.innerWidth < 768;
+ return (
+
e.target===e.currentTarget&&onClose()}>
+
+ {isMob&&
}
+
+
+ 🤝 {link.title}
+
+
+
+
+ setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
+ placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
+
+
+ {shares.length>0 ? shares.map(s=>(
+
+ {s.username}
+
+
+ )) :
Noch mit niemandem geteilt.
}
+
+
+ );
+}
+
+function LinkForm({ initial, onSave, onCancel, toast }) {
+ const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', ...initial });
+ const [busy, setBusy] = useState(false);
+ const set = (k,v) => setForm(p=>({...p,[k]:v}));
+ 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;
+ setBusy(true);
+ try { await onSave({...form, url}); }
+ catch(e) { toast(e.message,'error'); }
+ setBusy(false);
+ };
+ return (
+
+
{initial?.id ? 'LINK BEARBEITEN' : 'NEUER LINK'}
+
+
+
+ {ICONS.map(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}}/>
+
+
+
+
+
+ );
+}
+
+export default function Linkliste({ toast, mobile }) {
+ const [own, setOwn] = useState([]);
+ const [shared, setShared] = useState([]);
+ const [byMe, setByMe] = useState([]);
+ const [tab, setTab] = useState('own');
+ const [showForm, setShowForm] = useState(false);
+ const [editItem, setEditItem] = useState(null);
+ const [shareItem, setShareItem] = useState(null);
+ const [search, setSearch] = useState('');
+
+ const load = () => api('/tools/linkliste')
+ .then(d=>{ setOwn(d.own||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); })
+ .catch(()=>{});
+
+ useEffect(()=>{ load(); },[]);
+
+ const addLink = async form => {
+ const r = await api('/tools/linkliste',{body:form});
+ setOwn(p=>[r,...p]); 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 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;
+
+ const LinkCard = ({ link, isShared=false, isByMe=false }) => {
+ const favicon = extIcon(link.url);
+ return (
+
+
+ {/* Icon */}
+
+ {favicon
+ ?

{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} style={{width:20,height:20,objectFit:'contain'}} alt=""/>
+ : null}
+
{link.icon}
+
+ {/* Info */}
+
+
+ {link.title}
+
+
+ {link.url}
+
+ {link.description && (
+
+ {link.description}
+
+ )}
+ {isShared &&
von {link.owner_name}
}
+ {isByMe &&
→ {link.shared_with_name}
}
+
+
+ {/* Aktionen */}
+
+
+ {!isShared && !isByMe && (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+ };
+
+ return (
+
+ {shareItem &&
{setShareItem(null);load();}} toast={toast}/>}
+
+ {/* Header */}
+
+
Linkliste
+
+
+ {!showForm && !editItem && tab==='own' && (
+
+ )}
+
+
+
+ {/* Formular */}
+ {showForm && 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])=>(
+
+ ))}
+
+
+ {/* Suche */}
+
+ ⌕
+ setSearch(e.target.value)} placeholder="Suchen…"
+ 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.'}
+
+
+ ) : filtered.map(link=>(
+
+ ))}
+
+ );
+}