QR-Generator, Ordner-Sharing, Baustellenschild weg, Button-Fix
This commit is contained in:
@@ -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