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=>( + + ))} +
+ ); +}