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 ( +