QR-Generator, Ordner-Sharing, Baustellenschild weg, Button-Fix
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
41
backend/src/tools/qrcodes/routes.js
Normal file
41
backend/src/tools/qrcodes/routes.js
Normal file
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{display:'flex',gap:6,marginBottom:18}}>
|
||||
{tabs.map(t=>(
|
||||
<button key={t.id} onClick={()=>setTab(t.id)} style={{
|
||||
padding:'6px 16px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||||
background:tab===t.id?'#4ecdc4':'rgba(255,255,255,0.05)',
|
||||
color:tab===t.id?'#0d0d0f':'rgba(255,255,255,0.65)',
|
||||
border:tab===t.id?'none':'1px solid rgba(255,255,255,0.12)',
|
||||
fontWeight:tab===t.id?700:400}}>{t.label}</button>
|
||||
))}
|
||||
{editing && <span style={{color:'#ffe66d',fontFamily:'monospace',fontSize:10,alignSelf:'center',marginLeft:4}}>● Bearbeitung</span>}
|
||||
</div>
|
||||
|
||||
{tab==='create' && (
|
||||
<div style={{display:'grid',gridTemplateColumns:mobile?'1fr':'1fr 1fr',gap:16,alignItems:'start'}}>
|
||||
{/* Linke Spalte: Einstellungen */}
|
||||
<div>
|
||||
<div style={{marginBottom:10}}>
|
||||
<div style={lbl}>Name / Bezeichnung</div>
|
||||
<input style={{...inp,width:'100%'}} placeholder="z.B. Meine Webseite" value={form.label} onChange={e=>set('label',e.target.value)}/>
|
||||
</div>
|
||||
<div style={{marginBottom:10}}>
|
||||
<div style={lbl}>URL / Inhalt *</div>
|
||||
<input style={{...inp,width:'100%'}} placeholder="https://example.com" autoCapitalize="none" value={form.url} onChange={e=>set('url',e.target.value)}/>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
|
||||
<div>
|
||||
<div style={lbl}>Größe (px)</div>
|
||||
<select style={{...inp,width:'100%',cursor:'pointer'}} value={form.size} onChange={e=>set('size',parseInt(e.target.value))}>
|
||||
{QR_SIZES.map(s=><option key={s} value={s}>{s}×{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={lbl}>Rand (Module)</div>
|
||||
<input type="number" min={0} max={10} style={{...inp,width:'100%'}} value={form.margin} onChange={e=>set('margin',parseInt(e.target.value)||0)}/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
|
||||
<div>
|
||||
<div style={lbl}>Punkte-Stil</div>
|
||||
<select style={{...inp,width:'100%',cursor:'pointer'}} value={form.dotStyle} onChange={e=>set('dotStyle',e.target.value)}>
|
||||
{DOT_STYLES.map(d=><option key={d.id} value={d.id}>{d.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={lbl}>Ecken-Stil</div>
|
||||
<select style={{...inp,width:'100%',cursor:'pointer'}} value={form.cornerStyle} onChange={e=>set('cornerStyle',e.target.value)}>
|
||||
{CORNER_STYLES.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
|
||||
<div>
|
||||
<div style={lbl}>Farbe (Punkte)</div>
|
||||
<div style={{display:'flex',gap:6,alignItems:'center'}}>
|
||||
<input type="color" value={form.fgColor} onChange={e=>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'}}/>
|
||||
<input style={{...inp,flex:1,fontSize:11}} value={form.fgColor} onChange={e=>set('fgColor',e.target.value)}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={lbl}>Hintergrund</div>
|
||||
<div style={{display:'flex',gap:6,alignItems:'center'}}>
|
||||
<input type="color" value={form.bgColor} onChange={e=>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'}}/>
|
||||
<input style={{...inp,flex:1,fontSize:11}} value={form.bgColor} onChange={e=>set('bgColor',e.target.value)}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Beschriftung */}
|
||||
<div style={{padding:'10px 12px',borderRadius:8,background:'rgba(255,255,255,0.03)',
|
||||
border:'1px solid rgba(255,255,255,0.07)',marginBottom:10}}>
|
||||
<div style={{...lbl,marginBottom:8}}>BESCHRIFTUNG</div>
|
||||
<div style={{marginBottom:8}}>
|
||||
<div style={lbl}>Position</div>
|
||||
<div style={{display:'flex',gap:5}}>
|
||||
{CAP_POS.map(p=>(
|
||||
<button key={p.id} onClick={()=>set('captionPos',p.id)} style={{
|
||||
padding:'4px 12px',borderRadius:16,fontFamily:'monospace',fontSize:10,cursor:'pointer',
|
||||
background:form.captionPos===p.id?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
|
||||
border:`1px solid ${form.captionPos===p.id?'#4ecdc4':'rgba(255,255,255,0.12)'}`,
|
||||
color:form.captionPos===p.id?'#4ecdc4':'rgba(255,255,255,0.55)',fontWeight:form.captionPos===p.id?700:400}}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.captionPos !== 'none' && (
|
||||
<>
|
||||
<input style={{...inp,width:'100%',marginBottom:8,boxSizing:'border-box'}} placeholder="Text der Beschriftung"
|
||||
value={form.caption} onChange={e=>set('caption',e.target.value)}/>
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8}}>
|
||||
<div>
|
||||
<div style={lbl}>Farbe</div>
|
||||
<div style={{display:'flex',gap:5,alignItems:'center'}}>
|
||||
<input type="color" value={form.captionColor} onChange={e=>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'}}/>
|
||||
<input style={{...inp,flex:1,fontSize:10}} value={form.captionColor} onChange={e=>set('captionColor',e.target.value)}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={lbl}>Schriftgröße</div>
|
||||
<input type="number" min={8} max={36} style={{...inp,width:'100%'}}
|
||||
value={form.captionSize} onChange={e=>set('captionSize',parseInt(e.target.value)||14)}/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={download} disabled={!form.url.trim()}
|
||||
style={{...S.btn('#ffe66d',true),flex:1,textAlign:'center',padding:'8px 0',opacity:form.url.trim()?1:0.4}}>
|
||||
↓ PNG laden
|
||||
</button>
|
||||
<button onClick={save} disabled={busy||!form.url.trim()}
|
||||
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'8px 0',opacity:form.url.trim()?1:0.4}}>
|
||||
{busy?'…':editing?'✓ Aktualisieren':'💾 Speichern'}
|
||||
</button>
|
||||
{editing && <button onClick={()=>{setEditing(null);setForm(DEFAULTS);}}
|
||||
style={{...S.btn('#ff6b9d',true),padding:'0 12px'}}>✕</button>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Rechte Spalte: Vorschau */}
|
||||
<div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:12}}>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>VORSCHAU</div>
|
||||
{form.url.trim()
|
||||
? <canvas ref={canvasRef} style={{borderRadius:8,maxWidth:'100%',
|
||||
boxShadow:'0 4px 24px rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)'}}/>
|
||||
: <div style={{width:200,height:200,borderRadius:8,background:'rgba(255,255,255,0.04)',
|
||||
border:'1px solid rgba(255,255,255,0.08)',display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:11}}>URL eingeben →</span>
|
||||
</div>
|
||||
}
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,textAlign:'center',lineHeight:1.6}}>
|
||||
QR-Code wird lokal generiert<br/>Nichts wird ohne Speichern gespeichert
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab==='saved' && (
|
||||
<div>
|
||||
{saved.length===0
|
||||
? <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,padding:'20px 0',textAlign:'center'}}>
|
||||
Noch keine QR-Codes gespeichert
|
||||
</div>
|
||||
: <div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
{saved.map(qr=>(
|
||||
<SavedQrCard key={qr.id} qr={qr} onEdit={editQr} onDelete={deleteQr} toast={toast}/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.07)',overflow:'hidden'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 12px',cursor:'pointer'}}
|
||||
onClick={()=>setExpanded(p=>!p)}>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11,width:14}}>{expanded?'▾':'▸'}</span>
|
||||
<div style={{flex:1,minWidth:0}}>
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
|
||||
{qr.label||'QR-Code'}
|
||||
</div>
|
||||
<div style={{color:'rgba(78,205,196,0.6)',fontFamily:'monospace',fontSize:10,
|
||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{qr.url}</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:4}} onClick={e=>e.stopPropagation()}>
|
||||
<button onClick={download} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>↓</button>
|
||||
<button onClick={()=>onEdit(qr)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}>✎</button>
|
||||
<button onClick={()=>onDelete(qr.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 8px'}}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={{padding:'0 12px 12px',display:'flex',gap:14,flexWrap:'wrap',alignItems:'flex-start'}}>
|
||||
<canvas ref={canvasRef} style={{borderRadius:6,flexShrink:0,
|
||||
boxShadow:'0 2px 12px rgba(0,0,0,0.3)',border:'1px solid rgba(255,255,255,0.08)',maxWidth:192}}/>
|
||||
<div style={{fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.45)',lineHeight:2}}>
|
||||
{[['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])=>(
|
||||
<div key={k}><span style={{color:'rgba(255,255,255,0.25)'}}>{k}:</span> {v}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 }) {
|
||||
|
||||
@@ -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 (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
|
||||
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
|
||||
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
|
||||
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
|
||||
🤝 {folder.icon} {folder.name}
|
||||
</div>
|
||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:10}}>
|
||||
Ganzer Ordner inkl. aller Links wird geteilt
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,marginBottom:14}}>
|
||||
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
|
||||
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
|
||||
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
|
||||
</div>
|
||||
{shares.length>0 ? shares.map(s=>(
|
||||
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
|
||||
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
|
||||
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}>✕</button>
|
||||
</div>
|
||||
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
<QABadge active={!!folder.in_quickaccess}/> Schnellzugriff
|
||||
</button>
|
||||
<button onClick={()=>setEditFolder(folder)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}>✎</button>
|
||||
<button onClick={()=>setShareFolderItem(folder)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🤝</button>
|
||||
<button onClick={()=>deleteFolder(folder.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -433,6 +486,7 @@ export default function Linkliste({ toast, mobile }) {
|
||||
return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
|
||||
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
||||
{shareFolderItem && <FolderShareModal folder={shareFolderItem} onClose={()=>{setShareFolderItem(null);load();}} toast={toast}/>}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14,gap:8,flexWrap:'wrap'}}>
|
||||
@@ -511,9 +565,33 @@ export default function Linkliste({ toast, mobile }) {
|
||||
</div>
|
||||
)}
|
||||
{tab==='shared' && (
|
||||
filteredShared.length===0
|
||||
? <div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
|
||||
: filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)
|
||||
<div>
|
||||
{/* Geteilte Ordner */}
|
||||
{sharedFolders.map(folder=>(
|
||||
<div key={`sf-${folder.id}`} style={{marginBottom:8}}>
|
||||
<div style={{...S.card,padding:'10px 12px',borderRadius:'10px 10px 0 0',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
<span style={{fontSize:18}}>{folder.icon}</span>
|
||||
<div style={{flex:1}}>
|
||||
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
|
||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginLeft:8}}>von {folder.owner_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
|
||||
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
|
||||
{(folder.links||[]).map(link=><LinkCard key={link.id} link={link} isShared/>)}
|
||||
{(!folder.links||folder.links.length===0) && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px',textAlign:'center'}}>Ordner ist leer</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Einzelne Links */}
|
||||
{filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)}
|
||||
{sharedFolders.length===0&&filteredShared.length===0 && (
|
||||
<div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab==='byMe' && (
|
||||
filteredByMe.length===0
|
||||
|
||||
Reference in New Issue
Block a user