From fa1eca68678066da5471ebb52860284de7ed37a2 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 5 Jun 2026 00:28:53 +0200 Subject: [PATCH] QR-Generator, Ordner-Sharing, Baustellenschild weg, Button-Fix --- backend/src/db.js | 37 +++ backend/src/index.js | 5 +- backend/src/tools/linkliste/routes.js | 58 +++- backend/src/tools/qrcodes/routes.js | 41 +++ frontend/package.json | 3 +- frontend/src/toolRegistry.js | 8 +- frontend/src/tools/devtools.jsx | 402 +++++++++++++++++++++++++- frontend/src/tools/linkliste.jsx | 86 +++++- 8 files changed, 624 insertions(+), 16 deletions(-) create mode 100644 backend/src/tools/qrcodes/routes.js diff --git a/backend/src/db.js b/backend/src/db.js index 8c0fe48..f154885 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -467,4 +467,41 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='lin } +// ── QR-Codes ────────────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='qr_codes'").get()) { + db.exec(` + CREATE TABLE qr_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 256, + fg_color TEXT NOT NULL DEFAULT '#000000', + bg_color TEXT NOT NULL DEFAULT '#ffffff', + margin INTEGER NOT NULL DEFAULT 4, + dot_style TEXT NOT NULL DEFAULT 'square', + corner_style TEXT NOT NULL DEFAULT 'square', + caption TEXT NOT NULL DEFAULT '', + caption_pos TEXT NOT NULL DEFAULT 'bottom', + caption_color TEXT NOT NULL DEFAULT '#000000', + caption_size INTEGER NOT NULL DEFAULT 14, + created_at DATETIME DEFAULT NULL + ) + `); +} + +// ── Link-Folder Shares ──────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_folder_shares'").get()) { + db.exec(` + CREATE TABLE link_folder_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL REFERENCES link_list_folders(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(folder_id, shared_with) + ) + `); +} + module.exports = db; diff --git a/backend/src/index.js b/backend/src/index.js index fbaf709..2ae6824 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -67,6 +67,7 @@ fs.readdirSync(toolsDir, { withFileTypes: true }) }); app.use('/api/tools/snippets', require('./tools/snippets/routes')); +app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes')); const PUBLIC = path.join(__dirname, '../public'); @@ -78,9 +79,7 @@ app.get('/manifest.json', (_req, res) => { app.get('/sw.js', (_req, res) => { res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Service-Worker-Allowed', '/'); - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); - res.setHeader('Expires', '0'); + res.setHeader('Cache-Control', 'no-cache'); res.sendFile(path.join(PUBLIC, 'sw.js')); }); diff --git a/backend/src/tools/linkliste/routes.js b/backend/src/tools/linkliste/routes.js index 6658575..72fa2de 100644 --- a/backend/src/tools/linkliste/routes.js +++ b/backend/src/tools/linkliste/routes.js @@ -86,7 +86,32 @@ router.get('/', authenticate, (req, res) => { ORDER BY l.title ASC `).all(uid); - res.json({ own, folders, shared, sharedByMe }); + // Ordner die mit mir geteilt wurden (inkl. deren Links) + const sharedFolders = db.prepare(` + SELECT f.*, u.username as owner_name + FROM link_list_folders f + JOIN link_folder_shares s ON s.folder_id=f.id + JOIN users u ON u.id=f.user_id + WHERE s.shared_with=? + ORDER BY f.name ASC + `).all(uid); + + const sharedFolderLinks = sharedFolders.map(folder => ({ + ...folder, + links: db.prepare('SELECT * FROM link_list WHERE folder_id=? ORDER BY sort_order,id').all(folder.id) + })); + + // Ordner die ich geteilt habe + const sharedFoldersByMe = db.prepare(` + SELECT f.*, u.username as shared_with_name + FROM link_list_folders f + JOIN link_folder_shares s ON s.folder_id=f.id + JOIN users u ON u.id=s.shared_with + WHERE f.user_id=? + ORDER BY f.name ASC + `).all(uid); + + res.json({ own, folders, shared, sharedByMe, sharedFolders: sharedFolderLinks, sharedFoldersByMe }); }); router.post('/', authenticate, (req, res) => { @@ -153,4 +178,35 @@ router.delete('/:id/share/:userId', authenticate, (req, res) => { res.json({ ok: true }); }); +// ── Ordner-Shares ───────────────────────────────────────────────────────────── +router.get('/folders/:id/shares', 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 shares = db.prepare(` + SELECT u.id, u.username, s.created_at as shared_at + FROM link_folder_shares s JOIN users u ON u.id=s.shared_with + WHERE s.folder_id=? + `).all(f.id); + res.json(shares); +}); + +router.post('/folders/:id/share', 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 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_folder_shares (folder_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))`) + .run(f.id, req.user.id, target.id); + res.json({ ok: true }); +}); + +router.delete('/folders/:id/share/:userId', 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' }); + db.prepare('DELETE FROM link_folder_shares WHERE folder_id=? AND shared_with=?').run(f.id, req.params.userId); + res.json({ ok: true }); +}); + + module.exports = router; diff --git a/backend/src/tools/qrcodes/routes.js b/backend/src/tools/qrcodes/routes.js new file mode 100644 index 0000000..c18cff8 --- /dev/null +++ b/backend/src/tools/qrcodes/routes.js @@ -0,0 +1,41 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +router.get('/', authenticate, (req, res) => { + res.json(db.prepare('SELECT * FROM qr_codes WHERE user_id=? ORDER BY created_at DESC').all(req.user.id)); +}); + +router.post('/', authenticate, (req, res) => { + const uid = req.user.id; + const { label='', url, size=256, fg_color='#000000', bg_color='#ffffff', margin=4, + dot_style='square', corner_style='square', caption='', caption_pos='bottom', + caption_color='#000000', caption_size=14 } = req.body; + if (!url?.trim()) return res.status(400).json({ error: 'URL erforderlich' }); + const r = db.prepare(` + INSERT INTO qr_codes (user_id,label,url,size,fg_color,bg_color,margin,dot_style,corner_style,caption,caption_pos,caption_color,caption_size,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + `).run(uid, label.trim(), url.trim(), size, fg_color, bg_color, margin, dot_style, corner_style, caption.trim(), caption_pos, caption_color, caption_size); + res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/:id', authenticate, (req, res) => { + const qr = db.prepare('SELECT * FROM qr_codes WHERE id=? AND user_id=?').get(req.params.id, req.user.id); + if (!qr) return res.status(404).json({ error: 'Nicht gefunden' }); + const fields = ['label','url','size','fg_color','bg_color','margin','dot_style','corner_style','caption','caption_pos','caption_color','caption_size']; + const updates = {}; + fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; }); + if (!Object.keys(updates).length) return res.json(qr); + const sets = Object.keys(updates).map(f=>`${f}=?`).join(','); + db.prepare(`UPDATE qr_codes SET ${sets} WHERE id=?`).run(...Object.values(updates), qr.id); + res.json(db.prepare('SELECT * FROM qr_codes WHERE id=?').get(qr.id)); +}); + +router.delete('/:id', authenticate, (req, res) => { + const r = db.prepare('DELETE FROM qr_codes 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 }); +}); + +module.exports = router; diff --git a/frontend/package.json b/frontend/package.json index 27688e8..7e73b7b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build": "vite build" }, "dependencies": { + "qrcode": "^1.5.4", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -14,4 +15,4 @@ "@vitejs/plugin-react": "^4.0.0", "vite": "^5.0.0" } -} \ No newline at end of file +} diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index 51c5f47..13b9ce3 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -68,16 +68,16 @@ export const TOOLS = [ { id: 'skizze', Icon: SkizzeIcon, - label: 'CAD-Skizzen 🚧', - navLabel: 'CAD-Skizzen 🚧', + label: 'CAD-Skizzen', + navLabel: 'CAD-Skizzen', group: 'Werkzeuge', component: Skizze, }, { id: 'devtools', Icon: WrenchIcon, - label: 'Dev-Tools 🚧', - navLabel: 'Dev-Tools 🚧', + label: 'Dev-Tools', + navLabel: 'Dev-Tools', group: 'Werkzeuge', component: DevTools, }, diff --git a/frontend/src/tools/devtools.jsx b/frontend/src/tools/devtools.jsx index 0805f5d..3024597 100644 --- a/frontend/src/tools/devtools.jsx +++ b/frontend/src/tools/devtools.jsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; -import { S } from '../lib.js'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { S, api } from '../lib.js'; +import QRCode from 'qrcode'; const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; const WEEKDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; @@ -2678,6 +2679,401 @@ function FarbRechner({ toast, mobile }) { ); } +// ──────────────────────────────────────────────────────────── QR-Generator ── + +const QR_SIZES = [128, 192, 256, 320, 512]; +const DOT_STYLES = [ + { id:'square', label:'Quadrat' }, + { id:'rounded', label:'Abgerundet' }, + { id:'dots', label:'Punkte' }, + { id:'classy', label:'Elegant' }, +]; +const CORNER_STYLES = [ + { id:'square', label:'Quadrat' }, + { id:'rounded', label:'Abgerundet' }, + { id:'dot', label:'Punkt' }, +]; +const CAP_POS = [ + { id:'top', label:'Oben' }, + { id:'bottom', label:'Unten' }, + { id:'none', label:'Keine' }, +]; + +function drawQrToCanvas(canvas, data, opts) { + const { size, fgColor, bgColor, margin, dotStyle, cornerStyle, caption, captionPos, captionColor, captionSize } = opts; + const capH = caption && captionPos !== 'none' ? (captionSize + 16) : 0; + const totalH = size + capH; + canvas.width = size; + canvas.height = totalH; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, size, totalH); + + return QRCode.create(data, { errorCorrectionLevel: 'M' }).then(qr => { + const modules = qr.modules; + const n = modules.size; + const cellSize = (size - margin * 2) / n; + const qrTop = captionPos === 'top' ? capH : 0; + + ctx.fillStyle = fgColor; + + for (let row = 0; row < n; row++) { + for (let col = 0; col < n; col++) { + if (!modules.get(row, col)) continue; + const x = margin + col * cellSize; + const y = qrTop + margin + row * cellSize; + const w = cellSize; + + // Corner squares (finder patterns): rows/cols 0-7 and last 7 + const isCorner = (row < 7 && col < 7) || (row < 7 && col >= n-7) || (row >= n-7 && col < 7); + + if (isCorner) { + if (cornerStyle === 'dot') { + const r = w * 0.5; + ctx.beginPath(); ctx.arc(x+r, y+r, r, 0, Math.PI*2); ctx.fill(); + } else if (cornerStyle === 'rounded') { + const r = w * 0.35; + ctx.beginPath(); ctx.roundRect(x, y, w, w, r); ctx.fill(); + } else { + ctx.fillRect(x, y, w, w); + } + } else { + if (dotStyle === 'dots') { + const r = w * 0.45; + ctx.beginPath(); ctx.arc(x+r, y+r, r, 0, Math.PI*2); ctx.fill(); + } else if (dotStyle === 'rounded') { + const r = w * 0.3; + ctx.beginPath(); ctx.roundRect(x, y, w, w, r); ctx.fill(); + } else if (dotStyle === 'classy') { + const r = w * 0.4; + ctx.beginPath(); ctx.roundRect(x+1, y+1, w-2, w-2, r); ctx.fill(); + } else { + ctx.fillRect(x, y, w, w); + } + } + } + } + + // Caption + if (caption && captionPos !== 'none') { + ctx.fillStyle = captionColor; + ctx.font = `${captionSize}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const cy = captionPos === 'top' ? capH / 2 : size + capH / 2; + ctx.fillText(caption, size / 2, cy, size - 16); + } + }).catch(() => {}); +} + +function QrGenerator({ toast, mobile }) { + const canvasRef = useRef(null); + const [tab, setTab] = useState('create'); + const [saved, setSaved] = useState([]); + const [editing, setEditing] = useState(null); // saved qr being edited + + // Form state + const DEFAULTS = { + label:'', url:'', size:256, fgColor:'#000000', bgColor:'#ffffff', + margin:4, dotStyle:'square', cornerStyle:'square', + caption:'', captionPos:'bottom', captionColor:'#000000', captionSize:14, + }; + const [form, setForm] = useState(DEFAULTS); + const [busy, setBusy] = useState(false); + + const set = (k, v) => setForm(p => ({...p, [k]: v})); + + const loadSaved = () => api('/tools/qrcodes').then(setSaved).catch(()=>{}); + useEffect(()=>{ loadSaved(); },[]); + + // Re-render QR whenever form changes + useEffect(()=>{ + if (!canvasRef.current || !form.url.trim()) return; + drawQrToCanvas(canvasRef.current, form.url, { + size:form.size, fgColor:form.fgColor, bgColor:form.bgColor, + margin:form.margin, dotStyle:form.dotStyle, cornerStyle:form.cornerStyle, + caption:form.caption, captionPos:form.captionPos, + captionColor:form.captionColor, captionSize:form.captionSize, + }); + }, [form]); + + const download = () => { + if (!canvasRef.current) return; + const a = document.createElement('a'); + a.href = canvasRef.current.toDataURL('image/png'); + a.download = `qrcode-${form.label||'export'}.png`; + a.click(); + }; + + const save = async () => { + if (!form.url.trim()) { toast('URL erforderlich','error'); return; } + setBusy(true); + try { + const payload = { label:form.label, url:form.url, size:form.size, + fg_color:form.fgColor, bg_color:form.bgColor, margin:form.margin, + dot_style:form.dotStyle, corner_style:form.cornerStyle, + caption:form.caption, caption_pos:form.captionPos, + caption_color:form.captionColor, caption_size:form.captionSize }; + if (editing) { + const r = await api(`/tools/qrcodes/${editing}`, {method:'PUT', body:payload}); + setSaved(p=>p.map(q=>q.id===r.id?r:q)); + setEditing(null); + toast('Gespeichert ✓'); + } else { + const r = await api('/tools/qrcodes', {body:payload}); + setSaved(p=>[r,...p]); + toast('QR-Code gespeichert ✓'); + } + } catch(e) { toast(e.message,'error'); } + setBusy(false); + }; + + const deleteQr = async id => { + await api(`/tools/qrcodes/${id}`,{method:'DELETE'}); + setSaved(p=>p.filter(q=>q.id!==id)); + toast('Gelöscht'); + }; + + const editQr = qr => { + setForm({ label:qr.label, url:qr.url, size:qr.size, + fgColor:qr.fg_color, bgColor:qr.bg_color, margin:qr.margin, + dotStyle:qr.dot_style, cornerStyle:qr.corner_style, + caption:qr.caption, captionPos:qr.caption_pos, + captionColor:qr.caption_color, captionSize:qr.caption_size }); + setEditing(qr.id); + setTab('create'); + }; + + const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'}; + const lbl = {color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:3}; + + const tabs = [{id:'create',label:'✏️ Erstellen'},{id:'saved',label:`📋 Gespeichert (${saved.length})`}]; + + return ( +
+
+ {tabs.map(t=>( + + ))} + {editing && ● Bearbeitung} +
+ + {tab==='create' && ( +
+ {/* Linke Spalte: Einstellungen */} +
+
+
Name / Bezeichnung
+ set('label',e.target.value)}/> +
+
+
URL / Inhalt *
+ set('url',e.target.value)}/> +
+
+
+
Größe (px)
+ +
+
+
Rand (Module)
+ set('margin',parseInt(e.target.value)||0)}/> +
+
+
+
+
Punkte-Stil
+ +
+
+
Ecken-Stil
+ +
+
+
+
+
Farbe (Punkte)
+
+ set('fgColor',e.target.value)} + style={{width:38,height:34,borderRadius:6,border:'1px solid rgba(255,255,255,0.15)',cursor:'pointer',padding:2,background:'transparent'}}/> + set('fgColor',e.target.value)}/> +
+
+
+
Hintergrund
+
+ set('bgColor',e.target.value)} + style={{width:38,height:34,borderRadius:6,border:'1px solid rgba(255,255,255,0.15)',cursor:'pointer',padding:2,background:'transparent'}}/> + set('bgColor',e.target.value)}/> +
+
+
+ {/* Beschriftung */} +
+
BESCHRIFTUNG
+
+
Position
+
+ {CAP_POS.map(p=>( + + ))} +
+
+ {form.captionPos !== 'none' && ( + <> + set('caption',e.target.value)}/> +
+
+
Farbe
+
+ set('captionColor',e.target.value)} + style={{width:34,height:30,borderRadius:5,border:'1px solid rgba(255,255,255,0.15)',cursor:'pointer',padding:1,background:'transparent'}}/> + set('captionColor',e.target.value)}/> +
+
+
+
Schriftgröße
+ set('captionSize',parseInt(e.target.value)||14)}/> +
+
+ + )} +
+
+ + + {editing && } +
+
+ {/* Rechte Spalte: Vorschau */} +
+
VORSCHAU
+ {form.url.trim() + ? + :
+ URL eingeben → +
+ } +
+ QR-Code wird lokal generiert
Nichts wird ohne Speichern gespeichert +
+
+
+ )} + + {tab==='saved' && ( +
+ {saved.length===0 + ?
+ Noch keine QR-Codes gespeichert +
+ :
+ {saved.map(qr=>( + + ))} +
+ } +
+ )} +
+ ); +} + +function SavedQrCard({ qr, onEdit, onDelete, toast }) { + const canvasRef = useRef(null); + const [expanded, setExpanded] = useState(false); + + useEffect(()=>{ + if (!expanded || !canvasRef.current) return; + drawQrToCanvas(canvasRef.current, qr.url, { + size: Math.min(qr.size, 192), fgColor:qr.fg_color, bgColor:qr.bg_color, + margin:qr.margin, dotStyle:qr.dot_style, cornerStyle:qr.corner_style, + caption:qr.caption, captionPos:qr.caption_pos, + captionColor:qr.caption_color, captionSize:qr.caption_size, + }); + },[expanded, qr]); + + const download = () => { + if (!canvasRef.current) return; + // Re-draw in full size for download + const tmp = document.createElement('canvas'); + drawQrToCanvas(tmp, qr.url, { + size:qr.size, fgColor:qr.fg_color, bgColor:qr.bg_color, + margin:qr.margin, dotStyle:qr.dot_style, cornerStyle:qr.corner_style, + caption:qr.caption, captionPos:qr.caption_pos, + captionColor:qr.caption_color, captionSize:qr.caption_size, + }).then(()=>{ + const a = document.createElement('a'); + a.href = tmp.toDataURL('image/png'); + a.download = `qrcode-${qr.label||qr.id}.png`; + a.click(); + }); + }; + + return ( +
+
setExpanded(p=>!p)}> + {expanded?'▾':'▸'} +
+
+ {qr.label||'QR-Code'} +
+
{qr.url}
+
+
e.stopPropagation()}> + + + +
+
+ {expanded && ( +
+ +
+ {[['Größe',`${qr.size}×${qr.size}px`],['Punkte',qr.dot_style],['Ecken',qr.corner_style], + ['Rand',qr.margin],['Beschriftung',qr.caption||'—'],['Position',qr.caption_pos]].map(([k,v])=>( +
{k}: {v}
+ ))} +
+
+ )} +
+ ); +} + const TOOL_DEFS = [ {id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true, @@ -2690,7 +3086,7 @@ const TOOL_DEFS = [ {id:'netzwerk', label:'🌐 Netzwerk', desc:'IP, Subnetz, Geschwindigkeit, CIDR', ready:true, component:NetzwerkRechner}, {id:'datetime', label:'📅 Datetime', desc:'Zeiten umrechnen · Geburtstag · Differenz', ready:true, component:DatetimeRechner}, {id:'farben', label:'🎨 Farben', desc:'HEX, RGB, HSL, OKLCH umrechnen', ready:true, component:FarbRechner}, - {id:'keygen', label:'🔐 Key-Gen', desc:'Sichere Keys & Secrets generieren', ready:true, component:KeyGen}, + {id:'qrcode', label:'◻ QR-Code', desc:'QR-Codes erstellen & verwalten', ready:true, component:QrGenerator}, ]; export default function DevTools({ toast, mobile }) { diff --git a/frontend/src/tools/linkliste.jsx b/frontend/src/tools/linkliste.jsx index ccf5bfb..da67b2b 100644 --- a/frontend/src/tools/linkliste.jsx +++ b/frontend/src/tools/linkliste.jsx @@ -11,6 +11,55 @@ function extIcon(url) { function isMob() { return window.innerWidth < 768; } +// ── Folder Share Modal ──────────────────────────────────────────────────────── +function FolderShareModal({ folder, onClose, toast }) { + const [username, setUsername] = useState(''); + const [shares, setShares] = useState([]); + const load = () => api(`/tools/linkliste/folders/${folder.id}/shares`).then(setShares).catch(()=>{}); + useEffect(()=>{ load(); },[]); + const share = async () => { + if (!username.trim()) return; + try { await api(`/tools/linkliste/folders/${folder.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/folders/${folder.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); } + catch(e) { toast(e.message,'error'); } + }; + const mob = isMob(); + return ( +
e.target===e.currentTarget&&onClose()}> +
+ {mob&&
} +
+
+ 🤝 {folder.icon} {folder.name} +
+ +
+
+ Ganzer Ordner inkl. aller Links wird geteilt +
+
+ 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.
} +
+
+ ); +} + // ── Share Modal ─────────────────────────────────────────────────────────────── function ShareModal({ link, onClose, toast }) { const [username, setUsername] = useState(''); @@ -149,19 +198,22 @@ export default function Linkliste({ toast, mobile }) { const [folders, setFolders] = useState([]); const [shared, setShared] = useState([]); const [byMe, setByMe] = useState([]); + const [sharedFolders, setSharedFolders] = useState([]); + const [sharedFoldersByMe, setSharedFoldersByMe] = useState([]); const [tab, setTab] = useState('own'); const [showForm, setShowForm] = useState(false); const [showFolderForm, setShowFolderForm] = useState(false); const [editItem, setEditItem] = useState(null); const [editFolder,setEditFolder]= useState(null); const [shareItem, setShareItem] = useState(null); + const [shareFolderItem, setShareFolderItem] = useState(null); const [search, setSearch] = useState(''); const [expanded, setExpanded] = useState({}); // folder_id → bool const [sortMode, setSortMode] = useState(false); const dragRef = useRef(null); const load = () => api('/tools/linkliste') - .then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); }) + .then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); setSharedFolders(d.sharedFolders||[]); setSharedFoldersByMe(d.sharedFoldersByMe||[]); }) .catch(()=>{}); useEffect(()=>{ load(); },[]); @@ -400,6 +452,7 @@ export default function Linkliste({ toast, mobile }) { Schnellzugriff +
)} @@ -433,6 +486,7 @@ export default function Linkliste({ toast, mobile }) { return (
{shareItem && {setShareItem(null);load();}} toast={toast}/>} + {shareFolderItem && {setShareFolderItem(null);load();}} toast={toast}/>} {/* Header */}
@@ -511,9 +565,33 @@ export default function Linkliste({ toast, mobile }) {
)} {tab==='shared' && ( - filteredShared.length===0 - ?
Nichts geteilt.
- : filteredShared.map(link=>) +
+ {/* Geteilte Ordner */} + {sharedFolders.map(folder=>( +
+
+
+ {folder.icon} +
+ {folder.name} + von {folder.owner_name} +
+
+
+
+ {(folder.links||[]).map(link=>)} + {(!folder.links||folder.links.length===0) &&
Ordner ist leer
} +
+
+ ))} + {/* Einzelne Links */} + {filteredShared.map(link=>)} + {sharedFolders.length===0&&filteredShared.length===0 && ( +
Nichts geteilt.
+ )} +
)} {tab==='byMe' && ( filteredByMe.length===0