Files
dickendock/frontend/src/tools/devtools.jsx

3198 lines
159 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'];
function parseField(val) {
val = String(val).trim();
if (val==='*'||val==='?') return {type:'all'};
if (val.startsWith('*/')) { const n=parseInt(val.slice(2)); return {type:'step',step:n}; }
if (val.includes('/')&&!val.startsWith('*/')) { const [s,st]=val.split('/'); return {type:'stepFrom',from:parseInt(s),step:parseInt(st)}; }
if (val.includes(',')) return {type:'list',vals:val.split(',').map(v=>parseInt(v))};
if (val.includes('-')) { const [a,b]=val.split('-').map(Number); return {type:'range',from:a,to:b}; }
const n=parseInt(val); if (!isNaN(n)) return {type:'single',val:n};
return {type:'raw',val};
}
function describeField(f, names) {
switch(f.type) {
case 'all': return null;
case 'step': return `alle ${f.step}`;
case 'stepFrom': return `ab ${f.from} alle ${f.step}`;
case 'list': return f.vals.map(v=>names?.[v]||v).join(', ');
case 'range': return `${names?.[f.from]||f.from}${names?.[f.to]||f.to}`;
case 'single': return names?.[f.val] || String(f.val);
default: return f.val;
}
}
function explainCron(expr) {
const parts = expr.trim().split(/\s+/);
const specials = {'@yearly':'0 0 1 1 *','@annually':'0 0 1 1 *','@monthly':'0 0 1 * *',
'@weekly':'0 0 * * 0','@daily':'0 0 * * *','@midnight':'0 0 * * *','@hourly':'0 * * * *'};
if (parts.length===1) {
if (parts[0]==='@reboot') return {ok:true,human:'Beim Systemstart',fields:null};
if (specials[parts[0]]) return explainCron(specials[parts[0]]);
}
if (parts.length!==5) return {ok:false,error:'Ein Crontab besteht aus 5 Feldern: Minute Stunde Tag Monat Wochentag'};
const [minP,hrP,domP,monP,dowP] = parts;
const min=parseField(minP), hr=parseField(hrP), dom=parseField(domP), mon=parseField(monP), dow=parseField(dowP);
const out = [];
if (hr.type==='all'&&min.type==='all') out.push('Jede Minute');
else if (hr.type==='all'&&min.type==='step') out.push(`Alle ${min.step} Minuten`);
else if (hr.type==='all') out.push(`Jede Stunde um :${String(describeField(min)).padStart(2,'0')}`);
else if (min.type==='all') out.push(`Jede Minute der Stunde(n) ${describeField(hr)}`);
else { const m=describeField(min), h=describeField(hr); out.push(`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} Uhr`); }
if (dow.type!=='all') out.push(`jeden ${describeField(dow,WEEKDAYS)}`);
if (dom.type!=='all') { const d=describeField(dom); out.push(`am ${d}. des Monats`); }
if (mon.type!=='all') out.push(`im ${describeField(mon,MONTHS)}`);
return {ok:true,human:out.join(', '),fields:{min,hr,dom,mon,dow},raw:parts};
}
function nextRuns(expr, count=5) {
try {
const parts = expr.trim().split(/\s+/);
if (parts.length!==5) return [];
const [minP,hrP,domP,monP,dowP] = parts;
const fMin=parseField(minP), fHr=parseField(hrP), fDom=parseField(domP), fMon=parseField(monP), fDow=parseField(dowP);
const match = (f, v) => {
switch(f.type){
case 'all': return true;
case 'single': return v===f.val||(f.val===7&&v===0);
case 'range': return v>=f.from&&v<=f.to;
case 'step': return v%f.step===0;
case 'stepFrom': return v>=f.from&&(v-f.from)%f.step===0;
case 'list': return f.vals.includes(v);
default: return false;
}
};
const results=[], d=new Date();
d.setSeconds(0); d.setMilliseconds(0); d.setMinutes(d.getMinutes()+1);
for (let i=0;i<50000&&results.length<count;i++){
if (match(fMon,d.getMonth()+1)&&match(fDom,d.getDate())&&
(fDow.type==='all'||match(fDow,d.getDay()))&&
match(fHr,d.getHours())&&match(fMin,d.getMinutes())) results.push(new Date(d));
d.setMinutes(d.getMinutes()+1);
}
return results;
} catch { return []; }
}
// ── Erklärer ──────────────────────────────────────────────────────────────────
function CronExplainer() {
const [input, setInput] = useState('');
const expr = input.trim();
const result = expr ? explainCron(expr) : null;
const runs = result?.ok ? nextRuns(expr) : [];
const fmtDt = d => d.toLocaleString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
const EXAMPLES = [['0 8 * * 1','Mo 8:00'],['*/15 * * * *','alle 15 Min'],['0 0 1 * *','1. jeden Monat'],['30 18 * * 1-5','MoFr 18:30'],['0 9,17 * * *','9 & 17 Uhr'],['0 0 * * 0','So 0:00']];
return (
<div>
<div style={{marginBottom:10}}>
<label style={{...S.head,display:'block',marginBottom:6,fontSize:10}}>CRONTAB AUSDRUCK</label>
<input value={input} onChange={e=>setInput(e.target.value)} placeholder="z.B. 0 8 * * 1"
autoCapitalize="none" style={{...S.inp,fontFamily:"'Courier New',monospace",fontSize:16}}/>
</div>
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:16}}>
{EXAMPLES.map(([ex,l])=>(
<button key={ex} onClick={()=>setInput(ex)} style={{padding:'4px 10px',borderRadius:6,
fontFamily:'monospace',fontSize:10,cursor:'pointer',
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',color:'rgba(255,255,255,0.5)'}}>
<code>{ex}</code> <span style={{color:'rgba(255,255,255,0.5)',fontSize:9}}>({l})</span>
</button>
))}
</div>
{result && (result.ok ? (
<div>
<div style={{background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:10,padding:'14px 16px',marginBottom:12}}>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9,marginBottom:5}}>BEDEUTET</div>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:16,fontWeight:700}}>{result.human}</div>
</div>
{result.fields && (
<div style={{display:'grid',gridTemplateColumns:'repeat(5,1fr)',gap:8,marginBottom:12}}>
{[['MIN',0],[' STD',1],['TAG',2],['MON',3],['WTAG',4]].map(([lbl,i])=>{
const raw=expr.split(/\s+/)[i];
const fNames=i===3?MONTHS:i===4?WEEKDAYS:null;
const fd=Object.values(result.fields)[i];
return (
<div key={lbl} style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.07)',
borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>{lbl}</div>
<div style={{color:'#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:14,fontWeight:700}}>{raw}</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9,marginTop:3}}>
{fd?.type==='all'?'jede/r':(describeField(fd,fNames)||'')}
</div>
</div>
);
})}
</div>
)}
{runs.length>0&&(
<div>
<div style={{...S.head,marginBottom:6,fontSize:10}}>NÄCHSTE AUSFÜHRUNGEN</div>
{runs.map((d,i)=>(
<div key={i} style={{display:'flex',alignItems:'center',gap:10,padding:'5px 0',borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<span style={{color:'rgba(78,205,196,0.5)',fontFamily:'monospace',fontSize:10,minWidth:16,textAlign:'right'}}>{i+1}.</span>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:11}}>{fmtDt(d)}</span>
</div>
))}
</div>
)}
</div>
) : (
<div style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.2)',
borderRadius:8,padding:12,color:'#ff6b9d',fontFamily:'monospace',fontSize:11}}> {result.error}</div>
))}
</div>
);
}
// ── Builder ───────────────────────────────────────────────────────────────────
function CronBuilder() {
const [minute, setMinute] = useState('0');
const [hour, setHour] = useState('8');
const [dom, setDom] = useState('*');
const [month, setMonth] = useState('*');
const [dow, setDow] = useState('*');
const [preset, setPreset] = useState('');
const [copied, setCopied] = useState(false);
const cron = `${minute} ${hour} ${dom} ${month} ${dow}`;
const result = explainCron(cron);
const runs = result?.ok ? nextRuns(cron) : [];
const fmtDt = d => d.toLocaleString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
const copy = async () => { try { await navigator.clipboard.writeText(cron); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {} };
const PRESETS = [
{k:'every_min', l:'Jede Minute', v:['*','*','*','*','*']},
{k:'every_hour', l:'Jede Stunde', v:['0','*','*','*','*']},
{k:'every_day_8', l:'Tägl. 8:00', v:['0','8','*','*','*']},
{k:'every_mon', l:'Montag 8:00', v:['0','8','*','*','1']},
{k:'every_fri', l:'Freitag 17:00', v:['0','17','*','*','5']},
{k:'weekdays_9', l:'MoFr 9:00', v:['0','9','*','*','1-5']},
{k:'first_month', l:'1. des Monats', v:['0','0','1','*','*']},
{k:'midnight', l:'Mitternacht', v:['0','0','*','*','*']},
];
const applyPreset = p => {
setPreset(p.k);
const [mi,hr,dm,mo,dw] = p.v;
setMinute(mi); setHour(hr); setDom(dm); setMonth(mo); setDow(dw);
};
const Lbl = ({c}) => <div style={{...S.head,marginBottom:4,fontSize:9}}>{c}</div>;
const Sel = ({value,onChange,children}) => (
<select value={value} onChange={e=>{onChange(e.target.value);setPreset('');}}
style={{...S.inp,fontSize:12,padding:'6px 8px',width:'100%'}}>{children}</select>
);
return (
<div>
<div style={{marginBottom:14}}>
<Lbl c="SCHNELLAUSWAHL"/>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{PRESETS.map(p=>(
<button key={p.k} onClick={()=>applyPreset(p)} style={{
padding:'5px 11px',borderRadius:6,fontFamily:'monospace',fontSize:10,cursor:'pointer',
background:preset===p.k?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.05)',
border:`1px solid ${preset===p.k?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
color:preset===p.k?'#4ecdc4':'rgba(255,255,255,0.55)',
}}>{p.l}</button>
))}
</div>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:10,marginBottom:14}}>
<div><Lbl c="MINUTE"/>
<Sel value={minute} onChange={setMinute}>
<option value="*">* jede</option>
{[5,10,15,20,30].map(n=><option key={n} value={`*/${n}`}>alle {n} min</option>)}
{Array.from({length:60},(_,i)=><option key={i} value={i}>{String(i).padStart(2,'0')}</option>)}
</Sel>
</div>
<div><Lbl c="STUNDE"/>
<Sel value={hour} onChange={setHour}>
<option value="*">* jede</option>
{Array.from({length:24},(_,i)=><option key={i} value={i}>{String(i).padStart(2,'0')}:00</option>)}
<option value="9,17">9 & 17 Uhr</option>
<option value="8,12,17">8, 12 & 17 Uhr</option>
</Sel>
</div>
<div><Lbl c="WOCHENTAG"/>
<Sel value={dow} onChange={setDow}>
<option value="*">* jeder</option>
<option value="1-5">MoFr (Werktage)</option>
<option value="6,0">Sa & So (Wochenende)</option>
{WEEKDAYS.map((d,i)=><option key={i} value={i}>{d}</option>)}
</Sel>
</div>
<div><Lbl c="TAG DES MONATS"/>
<Sel value={dom} onChange={setDom}>
<option value="*">* jeder</option>
{Array.from({length:31},(_,i)=><option key={i+1} value={i+1}>{i+1}.</option>)}
<option value="1,15">1. & 15.</option>
</Sel>
</div>
<div><Lbl c="MONAT"/>
<Sel value={month} onChange={setMonth}>
<option value="*">* jeder</option>
{MONTHS.map((m,i)=><option key={i+1} value={i+1}>{m}</option>)}
</Sel>
</div>
</div>
<div style={{background:'rgba(0,0,0,0.3)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:10,padding:'14px 16px'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:10,flexWrap:'wrap',gap:8}}>
<code style={{color:'#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:22,fontWeight:700,letterSpacing:3}}>{cron}</code>
<button onClick={copy} style={{
background:copied?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.06)',
border:`1px solid ${copied?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
borderRadius:7,color:copied?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'6px 14px'}}>
{copied?'✓ Kopiert':'⎘ Kopieren'}
</button>
</div>
{result?.ok&&<div style={{color:'rgba(255,255,255,0.65)',fontFamily:'monospace',fontSize:13,marginBottom:10}}>{result.human}</div>}
{runs.length>0&&(
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{runs.slice(0,3).map((d,i)=>(
<span key={i} style={{background:'rgba(255,255,255,0.04)',borderRadius:5,padding:'3px 8px',
color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>{fmtDt(d)}</span>
))}
</div>
)}
</div>
</div>
);
}
// ── Tool-Definitionen ─────────────────────────────────────────────────────────
// ── Elektro-Hilfskomponenten (AUSSERHALB damit keine Remounts) ────────────────
function ElektroInp({ vals, setVals, k, label, unit, placeholder }) {
return (
<div style={{ flex:1, minWidth:100 }}>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:9, marginBottom:3 }}>
{label} [{unit}]
</div>
<input type="number" value={vals[k]||''} onChange={e=>setVals(p=>({...p,[k]:e.target.value}))}
placeholder={placeholder||''}
style={{ ...S.inp, fontSize:13, padding:'7px 9px', width:'100%', boxSizing:'border-box' }}/>
</div>
);
}
function ElektroResult({ label, value, highlight }) {
return (
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
padding:'8px 12px', marginBottom:5, borderRadius:8,
background: highlight ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${highlight ? 'rgba(78,205,196,0.25)' : 'rgba(255,255,255,0.07)'}` }}>
<span style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:11 }}>{label}</span>
<span style={{ color: highlight ? '#4ecdc4' : '#fff', fontFamily:"'Courier New',monospace",
fontSize:14, fontWeight:700 }}>{value}</span>
</div>
);
}
function ElektroSection({ title, children }) {
return (
<div style={{ marginBottom:20 }}>
<div style={{ ...S.head, marginBottom:10, color:'#4ecdc4' }}>{title}</div>
{children}
</div>
);
}
// ── Elektro-Rechner ───────────────────────────────────────────────────────────
function ElektroRechner({ mobile }) {
const [vals, setVals] = useState({});
const [preis, setPreis] = useState('0.38');
const n = k => { const v = parseFloat(vals[k]); return isNaN(v) ? null : v; };
const fmt = (v, unit, digits=3) => v == null ? '—' : `${parseFloat(v.toPrecision(digits))} ${unit}`;
// Res und Sec als direkte Aliase (kein Render-Problem, keine Inputs)
const cols = mobile ? '1fr' : '1fr 1fr';
// ── Ohmsches Gesetz ──
const U1=n('u1'), I1=n('i1'), R1=n('r1');
const ohmRes = {
U: U1 ?? (I1!=null&&R1!=null ? I1*R1 : null),
I: I1 ?? (U1!=null&&R1!=null ? U1/R1 : null),
R: R1 ?? (U1!=null&&I1!=null ? U1/I1 : null),
};
// ── Leistung P = U * I ──
const U2=n('u2'), I2=n('i2'), P2=n('p2'), R2=n('r2');
const leistRes = (() => {
if (U2!=null&&I2!=null) return { P:U2*I2, U:U2, I:I2, R:U2/I2 };
if (P2!=null&&U2!=null) return { P:P2, I:P2/U2, U:U2, R:U2*U2/P2 };
if (P2!=null&&I2!=null) return { P:P2, U:P2/I2, I:I2, R:P2/(I2*I2) };
if (P2!=null&&R2!=null) return { P:P2, U:Math.sqrt(P2*R2), I:Math.sqrt(P2/R2), R:R2 };
if (U2!=null&&R2!=null) return { P:U2*U2/R2, U:U2, I:U2/R2, R:R2 };
if (I2!=null&&R2!=null) return { P:I2*I2*R2, U:I2*R2, I:I2, R:R2 };
return null;
})();
// ── Energie & Kosten ──
const Pw=n('pw'), Std=n('std'), Tage=n('tage');
const kwhTag = Pw!=null&&Std!=null ? (Pw/1000)*Std : null;
const kwhMonat= kwhTag!=null&&Tage!=null ? kwhTag*Tage : (kwhTag!=null ? kwhTag*30 : null);
const kwhJahr = kwhTag!=null ? kwhTag*365 : null;
const ep = parseFloat(preis);
const kostenTag = kwhTag!=null ? kwhTag*ep : null;
const kostenMonat = kwhMonat!=null ? kwhMonat*ep : null;
const kostenJahr = kwhJahr!=null ? kwhJahr*ep : null;
// ── Wirkungsgrad ──
const Pa=n('pa'), Pe=n('pe');
const eta = Pa!=null&&Pe!=null&&Pe>0 ? (Pa/Pe)*100 : null;
const verlust = Pa!=null&&Pe!=null ? Pe-Pa : null;
// ── Serienschaltung Widerstände ──
const r_ser = ['rs1','rs2','rs3','rs4'].map(k=>n(k)).filter(v=>v!=null);
const R_ser = r_ser.reduce((a,b)=>a+b, 0) || null;
// ── Parallelschaltung Widerstände ──
const r_par = ['rp1','rp2','rp3','rp4'].map(k=>n(k)).filter(v=>v!=null);
const R_par = r_par.length ? 1/(r_par.reduce((a,b)=>a+1/b, 0)) : null;
// ── Kondensator Ladestrom ──
const Cap=n('cap'), Uc=n('uc'), Tc=n('tc');
const Ic = Cap!=null&&Uc!=null&&Tc!=null ? Cap*(Uc/Tc) : null;
return (
<div>
{/* Info */}
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:9,
marginBottom:16, lineHeight:1.8 }}>
Felder ausfüllen Ergebnis erscheint automatisch · Mindestens 2 bekannte Größen eingeben
</div>
<div style={{ display:'grid', gridTemplateColumns:cols, gap:20 }}>
<div>
{/* Ohmsches Gesetz */}
<ElektroSection title="⚡ Ohmsches Gesetz U = R · I">
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
<ElektroInp vals={vals} setVals={setVals} k="u1" label="Spannung" unit="V" placeholder="230"/>
<ElektroInp vals={vals} setVals={setVals} k="i1" label="Strom" unit="A" placeholder="2"/>
<ElektroInp vals={vals} setVals={setVals} k="r1" label="Widerstand" unit="Ω" placeholder="115"/>
</div>
<ElektroResult label="U Spannung" value={fmt(ohmRes.U,'V')} highlight={!U1&&ohmRes.U!=null}/>
<ElektroResult label="I Strom" value={fmt(ohmRes.I,'A')} highlight={!I1&&ohmRes.I!=null}/>
<ElektroResult label="R Widerstand" value={fmt(ohmRes.R,'Ω')} highlight={!R1&&ohmRes.R!=null}/>
</ElektroSection>
{/* Leistung */}
<ElektroSection title="🔋 Leistung P = U · I = U²/R = I²·R">
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
<ElektroInp vals={vals} setVals={setVals} k="u2" label="Spannung" unit="V"/>
<ElektroInp vals={vals} setVals={setVals} k="i2" label="Strom" unit="A"/>
<ElektroInp vals={vals} setVals={setVals} k="p2" label="Leistung" unit="W"/>
<ElektroInp vals={vals} setVals={setVals} k="r2" label="Widerstand" unit="Ω"/>
</div>
{leistRes ? <>
<ElektroResult label="P Leistung" value={fmt(leistRes.P,'W')} highlight={!P2}/>
<ElektroResult label="U Spannung" value={fmt(leistRes.U,'V')} highlight={!U2}/>
<ElektroResult label="I Strom" value={fmt(leistRes.I,'A')} highlight={!I2}/>
<ElektroResult label="R Widerstand" value={fmt(leistRes.R,'Ω')} highlight={!R2}/>
</> : <div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11}}>Mindestens 2 Werte eingeben</div>}
</ElektroSection>
{/* Wirkungsgrad */}
<ElektroSection title="🔄 Wirkungsgrad η = P_ab / P_zu">
<div style={{ display:'flex', gap:8, marginBottom:10 }}>
<ElektroInp vals={vals} setVals={setVals} k="pa" label="Abgabe-Leistung" unit="W" placeholder="800"/>
<ElektroInp vals={vals} setVals={setVals} k="pe" label="Aufnahme-Leistung" unit="W" placeholder="1000"/>
</div>
<ElektroResult label= Wirkungsgrad" value={eta!=null ? `${eta.toFixed(1)} %` : '—'} highlight={eta!=null}/>
<ElektroResult label="Verlustleistung" value={fmt(verlust,'W')}/>
</ElektroSection>
</div>
<div>
{/* Stromkosten */}
<ElektroSection title="💶 Stromkosten">
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
<ElektroInp vals={vals} setVals={setVals} k="pw" label="Leistung" unit="W" placeholder="100"/>
<ElektroInp vals={vals} setVals={setVals} k="std" label="Stunden/Tag" unit="h" placeholder="5"/>
<ElektroInp vals={vals} setVals={setVals} k="tage" label="Tage/Monat" unit="d" placeholder="30"/>
</div>
<div style={{ marginBottom:8 }}>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:9, marginBottom:3 }}>
Strompreis [/kWh]
</div>
<input type="number" value={preis} onChange={e=>setPreis(e.target.value)}
step="0.01" min="0"
style={{ ...S.inp, fontSize:13, padding:'7px 9px', width:'100%', boxSizing:'border-box' }}/>
</div>
<ElektroResult label="kWh/Tag" value={kwhTag!=null ? `${kwhTag.toFixed(3)} kWh` : '—'}/>
<ElektroResult label="Kosten/Tag" value={kostenTag!=null ? `${kostenTag.toFixed(3)}` : '—'}/>
<ElektroResult label="kWh/Monat" value={kwhMonat!=null ? `${kwhMonat.toFixed(2)} kWh` : '—'}/>
<ElektroResult label="Kosten/Monat" value={kostenMonat!=null ? `${kostenMonat.toFixed(2)}`: '—'} highlight={kostenMonat!=null}/>
<ElektroResult label="kWh/Jahr" value={kwhJahr!=null ? `${kwhJahr.toFixed(1)} kWh` : '—'}/>
<ElektroResult label="Kosten/Jahr" value={kostenJahr!=null ? `${kostenJahr.toFixed(2)}` : '—'} highlight={kostenJahr!=null}/>
</ElektroSection>
{/* Widerstände */}
<ElektroSection title="🔗 Widerstandsschaltung">
<div style={{ marginBottom:8 }}>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginBottom:5 }}>SERIE (R = R1+R2+)</div>
<div style={{ display:'flex', gap:6, flexWrap:'wrap', marginBottom:8 }}>
{['rs1','rs2','rs3','rs4'].map(k=><ElektroInp vals={vals} setVals={setVals} key={k} k={k} label={`R${k.slice(2)}`} unit="Ω"/>)}
</div>
<ElektroResult label="Gesamtwiderstand (Serie)" value={r_ser.length>=2 ? fmt(R_ser,'Ω') : '—'} highlight={r_ser.length>=2}/>
</div>
<div>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginBottom:5 }}>PARALLEL (1/R = 1/R1+1/R2+)</div>
<div style={{ display:'flex', gap:6, flexWrap:'wrap', marginBottom:8 }}>
{['rp1','rp2','rp3','rp4'].map(k=><ElektroInp vals={vals} setVals={setVals} key={k} k={k} label={`R${k.slice(2)}`} unit="Ω"/>)}
</div>
<ElektroResult label="Gesamtwiderstand (Parallel)" value={r_par.length>=2 ? fmt(R_par,'Ω') : '—'} highlight={r_par.length>=2}/>
</div>
</ElektroSection>
{/* Kondensator */}
<ElektroSection title="⚡ Kondensator I = C · (ΔU/Δt)">
<div style={{ display:'flex', gap:8, flexWrap:'wrap', marginBottom:10 }}>
<ElektroInp vals={vals} setVals={setVals} k="cap" label="Kapazität" unit="F" placeholder="0.001"/>
<ElektroInp vals={vals} setVals={setVals} k="uc" label="Spannung" unit="V" placeholder="230"/>
<ElektroInp vals={vals} setVals={setVals} k="tc" label="Zeit Δt" unit="s" placeholder="0.02"/>
</div>
<ElektroResult label="I Ladestrom" value={fmt(Ic,'A')} highlight={Ic!=null}/>
</ElektroSection>
</div>
</div>
{/* Reset */}
<button onClick={()=>setVals({})}
style={{ ...S.btn('#ff6b9d',true), marginTop:8, fontSize:11, padding:'6px 16px' }}>
Alle Felder leeren
</button>
</div>
);
}
// ─────────────────────────────────────────────────────────────── Datetime ──
// Moseszeit-Formel: moses + 94687200 = Unix-Timestamp (Sekunden)
// Anzeige immer als Europe/Berlin Lokalzeit (CEST=+2, CET=+1)
const MOSES_TO_UNIX_OFFSET = 94687200; // Sekunden
function pad2(n){ return String(n).padStart(2,'0'); }
function mosesToUnixMs(moses) {
return (moses + MOSES_TO_UNIX_OFFSET) * 1000;
}
function unixMsToMoses(unixMs) {
return Math.floor(unixMs / 1000) - MOSES_TO_UNIX_OFFSET;
}
// Date -> Berlin-Lokalzeit-String "DD.MM.YYYY HH:MM:SS"
function formatBerlin(d) {
return d.toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit',
hour12: false,
}).replace(',','');
}
// Date -> ISO-String in Berlin-Zeit für datetime-local input (YYYY-MM-DDTHH:MM:SS)
function toLocalISO(d) {
const s = d.toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' }); // sv-SE gibt YYYY-MM-DD HH:MM:SS
return s.replace(' ', 'T');
}
function formatDE(d){
return `${pad2(d.getDate())}.${pad2(d.getMonth()+1)}.${d.getFullYear()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
function DtRow({ label, value, muted }) {
return (
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 10px',borderRadius:7,background:'rgba(255,255,255,0.03)',marginBottom:4}}>
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:11}}>{label}</span>
<code style={{color: muted ? 'rgba(255,255,255,0.55)' : '#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:13,fontWeight:700}}>{value}</code>
</div>
);
}
function DatetimeRechner({ mobile }) {
const now = new Date();
// ── Tab 1: Umrechner ──
const [conv, setConv] = useState({ unix:'', local:'', de:'' });
const [convResult, setConvResult] = useState(null);
function convFromUnix(val) {
const n = parseInt(val);
if (isNaN(n)) { setConvResult(null); return; }
const unixMs = n * 1000;
const d = new Date(unixMs);
const moses = unixMsToMoses(unixMs);
setConvResult({
de: formatBerlin(d),
local: toLocalISO(d),
unix: String(n),
moses: String(moses),
wday: d.toLocaleDateString('de-DE', { timeZone:'Europe/Berlin', weekday:'long' }),
kw: getKW(d),
});
}
function convFromMoses(val) {
const n = parseInt(val);
if (isNaN(n)) { setConvResult(null); return; }
const unixMs = mosesToUnixMs(n);
const d = new Date(unixMs);
setConvResult({
de: formatBerlin(d),
local: toLocalISO(d),
unix: String(Math.floor(unixMs/1000)),
moses: String(n),
wday: d.toLocaleDateString('de-DE', { timeZone:'Europe/Berlin', weekday:'long' }),
kw: getKW(d),
});
}
function convFromLocal(val) {
if (!val) { setConvResult(null); return; }
// datetime-local gibt lokale Browser-Zeit — wir interpretieren als Europe/Berlin
const d = new Date(val); // browser local
if (isNaN(d)) { setConvResult(null); return; }
const unixMs = d.getTime();
setConvResult({
de: formatBerlin(d),
local: val,
unix: String(Math.floor(unixMs/1000)),
moses: String(unixMsToMoses(unixMs)),
wday: d.toLocaleDateString('de-DE', { timeZone:'Europe/Berlin', weekday:'long' }),
kw: getKW(d),
});
}
function getKW(d) {
const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay()||7));
const y = new Date(Date.UTC(tmp.getUTCFullYear(),0,1));
return Math.ceil((((tmp-y)/86400000)+1)/7);
}
// ── Tab 2: Geburtstag ──
const [bday, setBday] = useState('');
const [bdayResult, setBdayResult] = useState(null);
function calcBday(val) {
setBday(val);
if (!val) { setBdayResult(null); return; }
const birth = new Date(val);
if (isNaN(birth)) { setBdayResult(null); return; }
const today = new Date();
let ageY = today.getFullYear() - birth.getFullYear();
let ageM = today.getMonth() - birth.getMonth();
let ageD = today.getDate() - birth.getDate();
if (ageD < 0) { ageM--; const prev = new Date(today.getFullYear(), today.getMonth(), 0); ageD += prev.getDate(); }
if (ageM < 0) { ageY--; ageM += 12; }
const nextBday = new Date(today.getFullYear(), birth.getMonth(), birth.getDate());
if (nextBday <= today) nextBday.setFullYear(today.getFullYear()+1);
const diffMs = nextBday - today;
const diffD = (diffMs / 86400000).toFixed(1);
const totalD = ((today - birth) / 86400000).toFixed(1);
setBdayResult({
ageStr: `${ageY} Jahre, ${ageM} Monate, ${ageD} Tage`,
diffD, totalD,
wday: WEEKDAYS[birth.getDay()],
geburtstag: `${pad2(birth.getDate())}.${pad2(birth.getMonth()+1)}.${birth.getFullYear()}`,
nextDate: `${pad2(nextBday.getDate())}.${pad2(nextBday.getMonth()+1)}.${nextBday.getFullYear()} (${WEEKDAYS[nextBday.getDay()]})`,
});
}
// ── Tab 3: Zeitdifferenz ──
const [dtA, setDtA] = useState('');
const [dtATime, setDtATime] = useState('');
const [dtB, setDtB] = useState('');
const [dtBTime, setDtBTime] = useState('');
const [diffResult, setDiffResult] = useState(null);
function calcDiff(a, aTime, b, bTime) {
if (!a || !b) { setDiffResult(null); return; }
const strA = aTime ? `${a}T${aTime}:00` : `${a}T00:00:00`;
const strB = bTime ? `${b}T${bTime}:00` : `${b}T00:00:00`;
const dA = new Date(strA), dB = new Date(strB);
if (isNaN(dA)||isNaN(dB)) { setDiffResult(null); return; }
const hasTime = !!(aTime || bTime);
const ms = Math.abs(dB - dA);
const s = (ms / 1000).toFixed(1);
const m = (ms / 60000).toFixed(1);
const h = (ms / 3600000).toFixed(2);
const d = (ms / 86400000).toFixed(2);
const weeks = (ms / (86400000*7)).toFixed(2);
const dA2 = new Date(Math.min(dA,dB)), dB2 = new Date(Math.max(dA,dB));
const months = Math.abs(dB2.getMonth()-dA2.getMonth() + (dB2.getFullYear()-dA2.getFullYear())*12);
setDiffResult({ ms, s, m, h, d, weeks, months, hasTime });
}
const inp = { ...S.inp, fontSize:13, padding:'7px 9px', width:'100%', boxSizing:'border-box' };
const lbl = { color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:4 };
const [tab, setTab] = useState('conv');
const tabs = [{id:'conv',label:'⇄ Umrechnen'},{id:'bday',label:'🎂 Geburtstag'},{id:'diff',label:'⏱ Differenz'}];
return (
<div>
<div style={{display:'flex',gap:6,marginBottom:18,flexWrap:'wrap'}}>
{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>
))}
</div>
{tab==='conv' && (
<div>
<div style={{display:'grid',gridTemplateColumns:mobile?'1fr':'1fr 1fr 1fr',gap:12,marginBottom:16}}>
<div>
<div style={lbl}>Moseszeit</div>
<input style={inp} type="number" placeholder="z.B. 1685554093"
value={conv.moses||''} onChange={e=>{ setConv(p=>({...p,moses:e.target.value})); convFromMoses(e.target.value); }}/>
</div>
<div>
<div style={lbl}>Unix-Timestamp</div>
<input style={inp} type="number" placeholder="z.B. 1780248072"
value={conv.unix||''} onChange={e=>{ setConv(p=>({...p,unix:e.target.value})); convFromUnix(e.target.value); }}/>
</div>
<div>
<div style={lbl}>Datum & Uhrzeit (Berlin)</div>
<input style={inp} type="datetime-local"
value={conv.local||''} onChange={e=>{ setConv(p=>({...p,local:e.target.value})); convFromLocal(e.target.value); }}/>
</div>
</div>
<button style={{...S.btn('#ff6b9d',false), fontSize:11, padding:'6px 14px', marginBottom:16}}
onClick={()=>{ setConv({unix:'',moses:'',local:''}); setConvResult(null); }}>
Felder leeren
</button>
{convResult && (
<div>
<DtRow label="Datum/Uhrzeit (Berlin)" value={convResult.de}/>
<DtRow label="Unix-Timestamp" value={convResult.unix}/>
<DtRow label="Moseszeit" value={convResult.moses}/>
<DtRow label="Wochentag" value={convResult.wday}/>
<DtRow label="Kalenderwoche" value={`KW ${convResult.kw}`} muted/>
</div>
)}
{!convResult && (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,padding:'10px 0'}}>
Einen der drei Werte eingeben alle anderen werden berechnet
</div>
)}
<div style={{marginTop:14,padding:'10px 12px',borderRadius:8,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:9,marginBottom:6}}>FORMEL</div>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11}}>
Moses + <code style={{color:'#4ecdc4'}}>94.687.200</code> = Unix &nbsp;·&nbsp;
Moseszeit <code style={{color:'#4ecdc4'}}>1685554093</code> = Unix <code style={{color:'#4ecdc4'}}>1780241293</code> = <code style={{color:'#4ecdc4'}}>31.05.2026 17:28:13</code>
</div>
</div>
</div>
)}
{tab==='bday' && (
<div>
<div style={{display:'flex',alignItems:'flex-end',gap:10,marginBottom:16,flexWrap:'wrap'}}>
<div>
<div style={lbl}>Geburtsdatum</div>
<input style={{...inp, maxWidth:200}} type="date" value={bday}
onChange={e=>calcBday(e.target.value)}/>
</div>
{!!bday && <button style={{...S.btn('#ff6b9d',false), fontSize:11, padding:'6px 14px'}}
onClick={()=>{ setBday(''); setBdayResult(null); }}> Leeren</button>}
</div>
{bdayResult && (
<div>
<DtRow label="Geburtsdatum" value={`${bdayResult.geburtstag} (${bdayResult.wday})`}/>
<DtRow label="Alter" value={bdayResult.ageStr}/>
<DtRow label="Nächster Geburtstag" value={bdayResult.nextDate}/>
<DtRow label="Tage bis Geburtstag" value={`${bdayResult.diffD} Tage`}/>
<DtRow label="Gelebte Tage" value={`${Number(bdayResult.totalD).toLocaleString('de-DE')} Tage`} muted/>
</div>
)}
{!bdayResult && (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>
Geburtsdatum eingeben
</div>
)}
</div>
)}
{tab==='diff' && (
<div>
<div style={{display:'grid',gridTemplateColumns:mobile?'1fr':'1fr 1fr',gap:16,marginBottom:16}}>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
<div>
<div style={lbl}>Von Datum</div>
<input style={inp} type="date" value={dtA}
onChange={e=>{ setDtA(e.target.value); calcDiff(e.target.value, dtATime, dtB, dtBTime); }}/>
</div>
<div>
<div style={lbl}>Von Uhrzeit (optional)</div>
<input style={inp} type="time" value={dtATime}
onChange={e=>{ setDtATime(e.target.value); calcDiff(dtA, e.target.value, dtB, dtBTime); }}/>
</div>
</div>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
<div>
<div style={lbl}>Bis Datum</div>
<input style={inp} type="date" value={dtB}
onChange={e=>{ setDtB(e.target.value); calcDiff(dtA, dtATime, e.target.value, dtBTime); }}/>
</div>
<div>
<div style={lbl}>Bis Uhrzeit (optional)</div>
<input style={inp} type="time" value={dtBTime}
onChange={e=>{ setDtBTime(e.target.value); calcDiff(dtA, dtATime, dtB, e.target.value); }}/>
</div>
</div>
</div>
{!!(dtA||dtB||dtATime||dtBTime) && <button style={{...S.btn('#ff6b9d',false), fontSize:11, padding:'6px 14px', marginBottom:16}}
onClick={()=>{ setDtA(''); setDtB(''); setDtATime(''); setDtBTime(''); setDiffResult(null); }}> Felder leeren</button>}
{diffResult && (
<div>
{!!diffResult.hasTime && <DtRow label="Millisekunden" value={Number(diffResult.ms).toLocaleString('de-DE')} muted/>}
{!!diffResult.hasTime && <DtRow label="Sekunden" value={Number(diffResult.s).toLocaleString('de-DE')}/>}
{!!diffResult.hasTime && <DtRow label="Minuten" value={Number(diffResult.m).toLocaleString('de-DE')}/>}
{!!diffResult.hasTime && <DtRow label="Stunden" value={Number(diffResult.h).toLocaleString('de-DE')}/>}
<DtRow label="Tage" value={Number(diffResult.d).toLocaleString('de-DE')}/>
<DtRow label="Wochen" value={Number(diffResult.weeks).toLocaleString('de-DE')} muted/>
<DtRow label="Monate (ca.)" value={`~${diffResult.months}`} muted/>
</div>
)}
{!diffResult && (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>
Zwei Datumswerte eingeben · Uhrzeit ist optional
</div>
)}
</div>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────── Key-Gen ──
const KEY_TYPES = [
{ id: 'laravel', label: 'Laravel APP_KEY', desc: 'base64, 32 Byte', example: 'APP_KEY= in .env bei Laravel-Projekten verschlüsselt Sessions & Daten',
gen: () => 'base64:' + btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))) },
{ id: 'jwt_hs256', label: 'JWT Secret (HS256)', desc: '64 Byte hex', example: 'JWT_SECRET= in Node.js/Express-APIs signiert Login-Tokens',
gen: () => hex(64) },
{ id: 'jwt_hs512', label: 'JWT Secret (HS512)', desc: '128 Byte hex', example: 'Wie HS256, aber stärker für sicherheitskritische APIs empfohlen',
gen: () => hex(128) },
{ id: 'aes256', label: 'AES-256 Key', desc: '32 Byte base64', example: 'Symmetrische Verschlüsselung von Dateien oder Datenbank-Feldern',
gen: () => b64(32) },
{ id: 'aes128', label: 'AES-128 Key', desc: '16 Byte base64', example: 'Leichtgewichtige Verschlüsselung, z.B. für eingebettete Systeme',
gen: () => b64(16) },
{ id: 'secret32', label: 'Generic Secret (32B)', desc: '32 Byte hex', example: 'NEXTAUTH_SECRET=, SESSION_SECRET= oder ähnliche Umgebungsvariablen',
gen: () => hex(32) },
{ id: 'secret64', label: 'Generic Secret (64B)', desc: '64 Byte hex', example: 'Webhook-Signaturen (z.B. GitHub, Stripe) oder API-Signing-Keys',
gen: () => hex(64) },
{ id: 'passwd', label: 'Passwort (stark)', desc: '20 Zeichen, alle Typen', example: 'Admin-Passwörter, Service-Accounts oder initiale DB-Root-Passwörter',
gen: () => genPasswd(20) },
{ id: 'passwd16', label: 'Passwort (kurz)', desc: '16 Zeichen, alle Typen', example: 'Nutzer-Initialpasswörter oder Zugänge mit Zeichenlimit',
gen: () => genPasswd(16) },
{ id: 'uuid', label: 'UUID v4', desc: 'zufällige UUID', example: 'Eindeutige IDs für Datenbank-Einträge, Dateinamen oder API-Ressourcen',
gen: () => crypto.randomUUID() },
{ id: 'nanoid', label: 'NanoID (21)', desc: 'URL-safe, 21 Zeichen', example: 'Kurze IDs in URLs (z.B. share-Links) kompakter als UUID',
gen: () => nanoid(21) },
{ id: 'nanoid32', label: 'NanoID (32)', desc: 'URL-safe, 32 Zeichen', example: 'Session-IDs oder Token in URLs ohne Sonderzeichen-Probleme',
gen: () => nanoid(32) },
{ id: 'docker_pw', label: 'DB-Passwort (Docker)', desc: '24 Zeichen, alnum+sonder', example: 'MYSQL_ROOT_PASSWORD=, POSTGRES_PASSWORD= in docker-compose.yml',
gen: () => genPasswd(24) },
{ id: 'hex16', label: 'Hex Token (16B)', desc: '32 Hex-Zeichen', example: 'Kurze API-Keys, CSRF-Tokens oder Einmal-Codes',
gen: () => hex(16) },
{ id: 'hex32', label: 'Hex Token (32B)', desc: '64 Hex-Zeichen', example: 'Passwort-Reset-Links, E-Mail-Verifikations-Tokens',
gen: () => hex(32) },
{ id: 'b64_32', label: 'Base64 Token (32B)', desc: '32 Byte base64', example: 'Cookie-Signing-Keys oder kompakte Secrets in YAML/JSON-Configs',
gen: () => b64(32) },
{ id: 'b64_64', label: 'Base64 Token (64B)', desc: '64 Byte base64', example: 'VAPID-Keys (Web Push), OAuth Client Secrets',
gen: () => b64(64) },
{ id: 'b64url_32', label: 'Base64url Token (32B)', desc: '32 Byte base64url', example: 'OAuth2 state/code_verifier, JWT-kompatible Token ohne Padding',
gen: () => b64url(32) },
];
function hex(bytes) {
return Array.from(crypto.getRandomValues(new Uint8Array(bytes))).map(b=>b.toString(16).padStart(2,'0')).join('');
}
function b64(bytes) {
return btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(bytes))));
}
function b64url(bytes) {
return b64(bytes).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
function nanoid(len) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const rnd = crypto.getRandomValues(new Uint8Array(len));
return Array.from(rnd).map(b => chars[b % 64]).join('');
}
function genPasswd(len) {
const sets = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz','0123456789','!@#$%^&*()-_=+[]{}'];
const all = sets.join('');
const rnd = crypto.getRandomValues(new Uint8Array(len + 8));
let result = sets.map(s => s[rnd[sets.indexOf(s)] % s.length]); // guarantee 1 from each set
let idx = 4;
while (result.length < len) { result.push(all[rnd[idx++ % rnd.length] % all.length]); }
// shuffle
for (let i = result.length - 1; i > 0; i--) {
const j = rnd[(i * 3) % rnd.length] % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result.join('');
}
function KeyGen({ toast }) {
const [keys, setKeys] = useState({});
const [copied, setCopied] = useState(null);
function generate(id) {
const kt = KEY_TYPES.find(k => k.id === id);
if (!kt) return;
setKeys(p => ({ ...p, [id]: kt.gen() }));
}
function generateAll() {
const all = {};
KEY_TYPES.forEach(kt => { all[kt.id] = kt.gen(); });
setKeys(all);
}
function clearAll() { setKeys({}); }
async function copyKey(id, val) {
try {
await navigator.clipboard.writeText(val);
setCopied(id);
setTimeout(() => setCopied(null), 1800);
} catch { toast('Kopieren fehlgeschlagen'); }
}
return (
<div>
<div style={{display:'flex',gap:8,marginBottom:20,flexWrap:'wrap'}}>
<button style={{...S.btn('#4ecdc4',false), fontSize:11, padding:'7px 16px'}}
onClick={generateAll}> Alle generieren</button>
{!!Object.keys(keys).length && (
<button style={{...S.btn('#ff6b9d',false), fontSize:11, padding:'7px 16px'}}
onClick={clearAll}> Alle leeren</button>
)}
</div>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{KEY_TYPES.map(kt => (
<div key={kt.id} 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:'8px 12px',
borderBottom: keys[kt.id] ? '1px solid rgba(255,255,255,0.06)' : 'none'}}>
<div style={{flex:1,minWidth:0}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:12,fontWeight:600}}>{kt.label}</span>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginLeft:8}}>{kt.desc}</span>
<div style={{color:'rgba(255,255,255,0.45)',fontSize:11,marginTop:2,fontFamily:'sans-serif'}}>{kt.example}</div>
</div>
<button style={{...S.btn('#4ecdc4',false), fontSize:10, padding:'4px 12px', flexShrink:0}}
onClick={()=>generate(kt.id)}> Neu</button>
{!!keys[kt.id] && (
<button style={{...S.btn('#ff6b9d',false), fontSize:10, padding:'4px 10px', flexShrink:0}}
onClick={()=>setKeys(p=>({...p,[kt.id]:undefined}))}></button>
)}
</div>
{!!keys[kt.id] && (
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',cursor:'pointer'}}
onClick={()=>copyKey(kt.id, keys[kt.id])} title="Klicken zum Kopieren">
<code style={{
flex:1,minWidth:0,
color: copied===kt.id ? '#4ecdc4' : 'rgba(255,255,255,0.85)',
fontFamily:"'Courier New',monospace", fontSize:11,
wordBreak:'break-all', lineHeight:1.5}}>
{keys[kt.id]}
</code>
<span style={{
color: copied===kt.id ? '#4ecdc4' : 'rgba(255,255,255,0.35)',
fontSize:16, flexShrink:0, transition:'color 0.2s'}}>
{copied===kt.id ? '✓' : '⎘'}
</span>
</div>
)}
</div>
))}
</div>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:14,lineHeight:1.7}}>
Alle Keys werden lokal im Browser generiert (Web Crypto API) · Nichts wird gespeichert · Beim Seitenwechsel verschwinden sie
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────── Text Diff ──
function diffLines(a, b) {
const aLines = a.split('\n');
const bLines = b.split('\n');
const m = aLines.length, n = bLines.length;
const dp = Array.from({length: m+1}, () => new Array(n+1).fill(0));
for (let i = m-1; i >= 0; i--)
for (let j = n-1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j] ? dp[i+1][j+1]+1 : Math.max(dp[i+1][j], dp[i][j+1]);
// Build rows: same / del / add — consecutive del+add stay paired (same display line number)
const rows = []; // {type, lineA, lineB, text}
let i = 0, j = 0, lnA = 0, lnB = 0;
while (i < m || j < n) {
if (i < m && j < n && aLines[i] === bLines[j]) {
lnA++; lnB++;
rows.push({type:'same', ln: lnA, text: aLines[i]});
i++; j++;
} else if (j < n && (i >= m || dp[i][j+1] >= dp[i+1][j])) {
lnB++;
rows.push({type:'add', ln: lnB, text: bLines[j]});
j++;
} else {
lnA++;
rows.push({type:'del', ln: lnA, text: aLines[i]});
i++;
}
}
return rows;
}
// Which line numbers (1-based) in text A/B are changed
function changedLineNums(rows, side) {
const set = new Set();
rows.forEach(r => {
if (r.type === 'del' && side === 'a') set.add(r.ln);
if (r.type === 'add' && side === 'b') set.add(r.ln);
});
return set;
}
function TextDiff({ mobile }) {
const [left, setLeft] = useState('');
const [right, setRight] = useState('');
const [diff, setDiff] = useState(null);
function compare() {
if (!left.trim() && !right.trim()) return;
setDiff(diffLines(left, right));
}
function clear() { setLeft(''); setRight(''); setDiff(null); }
function loadFile(e, setter) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => { setter(ev.target.result); setDiff(null); };
reader.readAsText(file);
e.target.value = '';
}
const added = diff ? diff.filter(d=>d.type==='add').length : 0;
const removed = diff ? diff.filter(d=>d.type==='del').length : 0;
const same = diff ? diff.filter(d=>d.type==='same').length : 0;
const changedA = diff ? changedLineNums(diff, 'a') : new Set();
const changedB = diff ? changedLineNums(diff, 'b') : new Set();
const ta = { ...S.inp, fontSize:12, fontFamily:"'Courier New',monospace", width:'100%',
boxSizing:'border-box', minHeight:180, resize:'vertical', lineHeight:'1.6', padding:'7px 8px',
whiteSpace:'pre', overflowX:'auto', overflowWrap:'normal' };
function LineNumCol({ text, changedSet }) {
return (
<div style={{
padding:'7px 6px', minWidth:36, textAlign:'right', userSelect:'none',
fontFamily:"'Courier New',monospace", fontSize:12, lineHeight:'1.6',
background:'rgba(0,0,0,0.2)', borderRight:'1px solid rgba(255,255,255,0.07)',
flexShrink:0, overflowY:'hidden'}}>
{(text||' ').split('\n').map((_,i) => (
<div key={i} style={{
height:'1.6em',
color: changedSet.has(i+1) ? '#ff6b9d' : 'rgba(255,255,255,0.25)',
background: changedSet.has(i+1) ? 'rgba(255,107,157,0.12)' : 'transparent',
}}>{i+1}</div>
))}
</div>
);
}
return (
<div>
<div style={{display:'grid', gridTemplateColumns: mobile?'1fr':'1fr 1fr', gap:12, marginBottom:12}}>
{([['left','Text A (Original)', left, setLeft, changedA], ['right','Text B (Neu)', right, setRight, changedB]]).map(([side, label, val, setter, cset])=>(
<div key={side}>
<div style={{display:'flex', alignItems:'center', gap:8, marginBottom:6}}>
<span style={{color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, flex:1}}>{label}</span>
<label style={{...S.btn('#4ecdc4',false), fontSize:10, padding:'3px 10px', cursor:'pointer'}}>
📂 Datei laden
<input type="file" accept=".txt,.md,.log,.csv,.json,.xml,.yaml,.yml,.js,.ts,.jsx,.tsx,.py,.php,.sh,.env"
style={{display:'none'}} onChange={e=>loadFile(e,setter)}/>
</label>
</div>
<div style={{display:'flex', borderRadius:6, overflow:'hidden', border:'1px solid rgba(255,255,255,0.1)', background:'rgba(255,255,255,0.04)'}}>
<LineNumCol text={val} changedSet={cset}/>
<textarea style={{...ta, border:'none', borderRadius:0, background:'transparent', flex:1, resize:'vertical'}}
wrap="off" value={val} placeholder="Text hier einfügen…"
onChange={e=>{ setter(e.target.value); setDiff(null); }}/>
</div>
</div>
))}
</div>
<div style={{display:'flex', gap:8, marginBottom:16, flexWrap:'wrap'}}>
<button style={{...S.btn('#4ecdc4',true), fontSize:12, padding:'8px 20px'}}
onClick={compare}>Vergleichen</button>
{!!(left||right||diff) && (
<button style={{...S.btn('#ff6b9d',false), fontSize:11, padding:'8px 16px'}}
onClick={clear}> Leeren</button>
)}
{!!diff && (
<div style={{display:'flex', gap:10, alignItems:'center', flexWrap:'wrap'}}>
<span style={{background:'rgba(255,107,157,0.15)', color:'#ff6b9d', fontFamily:'monospace',
fontSize:11, padding:'4px 10px', borderRadius:6}}> {removed} entfernt</span>
<span style={{background:'rgba(78,205,196,0.15)', color:'#4ecdc4', fontFamily:'monospace',
fontSize:11, padding:'4px 10px', borderRadius:6}}>+ {added} hinzugefügt</span>
<span style={{background:'rgba(255,255,255,0.05)', color:'rgba(255,255,255,0.45)', fontFamily:'monospace',
fontSize:11, padding:'4px 10px', borderRadius:6}}>= {same} gleich</span>
</div>
)}
</div>
{!!diff && (
<div style={{borderRadius:9, border:'1px solid rgba(255,255,255,0.08)',
overflow:'auto', maxHeight:520, background:'rgba(0,0,0,0.2)'}}>
<table style={{width:'100%', borderCollapse:'collapse', fontFamily:"'Courier New',monospace", fontSize:12}}>
<tbody>
{diff.map((d,i) => (
<tr key={i} style={{
background: d.type==='add' ? 'rgba(78,205,196,0.1)' : d.type==='del' ? 'rgba(255,107,157,0.1)' : 'transparent',
borderBottom:'1px solid rgba(255,255,255,0.03)'}}>
<td style={{width:36, textAlign:'right', padding:'2px 8px', userSelect:'none',
color:'rgba(255,255,255,0.2)', fontSize:11,
borderRight:'1px solid rgba(255,255,255,0.05)'}}>
{d.ln}
</td>
<td style={{width:20, textAlign:'center', padding:'2px 4px', userSelect:'none',
color: d.type==='add'?'#4ecdc4': d.type==='del'?'#ff6b9d':'rgba(255,255,255,0.15)',
fontWeight:700, fontSize:14,
borderRight:'1px solid rgba(255,255,255,0.05)'}}>
{d.type==='add'?'+': d.type==='del'?'':''}
</td>
<td style={{padding:'3px 10px', whiteSpace:'pre-wrap', wordBreak:'break-all',
color: d.type==='add'?'#a8f0eb': d.type==='del'?'#ffb3cc':'rgba(255,255,255,0.65)'}}>
{d.text || ' '}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!diff && (
<div style={{color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:11,
padding:'20px 0', textAlign:'center'}}>
Texte eingeben oder Dateien laden Vergleichen klicken
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────── Netzwerk ──
function NetzwerkRechner({ mobile }) {
const [nTab, setNTab] = useState('speed');
// ── Geschwindigkeit ──
const [speedVal, setSpeedVal] = useState('');
const [speedUnit, setSpeedUnit] = useState('mbps');
const [customSize, setCustomSize] = useState('');
const [customSizeUnit, setCustomSizeUnit] = useState('mb');
const SPEED_UNITS = [
{ id:'bps', label:'bit/s', toBps: 1 },
{ id:'kbps', label:'kbit/s', toBps: 1e3 },
{ id:'mbps', label:'Mbit/s', toBps: 1e6 },
{ id:'gbps', label:'Gbit/s', toBps: 1e9 },
{ id:'bBps', label:'B/s', toBps: 8 },
{ id:'kBps', label:'kB/s', toBps: 8e3 },
{ id:'mBps', label:'MB/s', toBps: 8e6 },
{ id:'gBps', label:'GB/s', toBps: 8e9 },
];
function fmtSpeed(bps, u) {
const v = bps / u.toBps;
if (v >= 1000) return v.toLocaleString('de-DE', {maximumFractionDigits:2}) + ' ' + u.label;
return parseFloat(v.toPrecision(5)).toLocaleString('de-DE') + ' ' + u.label;
}
const speedBps = parseFloat(speedVal) * (SPEED_UNITS.find(u=>u.id===speedUnit)?.toBps||1e6);
const speedValid = !isNaN(speedBps) && speedVal !== '';
// Download-Zeit
function dlTime(bps, sizeBytes) {
const s = (sizeBytes * 8) / bps;
if (s < 60) return `${s.toFixed(1)} Sek.`;
if (s < 3600) return `${(s/60).toFixed(1)} Min.`;
return `${(s/3600).toFixed(2)} Std.`;
}
// ── IP / Subnetz ──
const [ipInput, setIpInput] = useState('');
const [subnetResult, setSubnetResult] = useState(null);
const [subnetErr, setSubnetErr] = useState('');
function parseIP(str) {
const parts = str.trim().split('.').map(Number);
if (parts.length !== 4 || parts.some(p=>isNaN(p)||p<0||p>255)) return null;
return parts.reduce((acc,p)=>(acc<<8)|p, 0) >>> 0;
}
function calcSubnet(input) {
setIpInput(input);
setSubnetErr('');
setSubnetResult(null);
if (!input.trim()) return;
let ip, prefix;
if (input.includes('/')) {
const [ipPart, pfx] = input.split('/');
ip = parseIP(ipPart);
prefix = parseInt(pfx);
if (ip === null || isNaN(prefix) || prefix < 0 || prefix > 32) { setSubnetErr('Ungültige CIDR-Notation'); return; }
} else {
ip = parseIP(input);
if (ip === null) { setSubnetErr('Ungültige IP-Adresse'); return; }
prefix = 24;
}
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
const net = (ip & mask) >>> 0;
const bcast = (net | (~mask >>> 0)) >>> 0;
const hosts = prefix >= 31 ? (prefix===32?1:2) : Math.pow(2, 32-prefix) - 2;
const first = prefix >= 31 ? net : (net + 1) >>> 0;
const last = prefix >= 31 ? bcast : (bcast - 1) >>> 0;
function i2ip(n) { return [(n>>>24)&0xff,(n>>>16)&0xff,(n>>>8)&0xff,n&0xff].join('.'); }
function i2mask(n) { return i2ip(n); }
// Klasse
const first8 = (ip>>>24)&0xff;
const klasse = first8 < 128?'A': first8<192?'B': first8<224?'C': first8<240?'D (Multicast)':'E (Reserviert)';
const privat = (first8===10)||(first8===172&&((ip>>>16)&0xff)>=16&&((ip>>>16)&0xff)<=31)||
(first8===192&&((ip>>>16)&0xff)===168);
setSubnetResult({
ip: i2ip(ip), cidr: `${i2ip(net)}/${prefix}`,
mask: i2mask(mask), maskBin: mask.toString(2).padStart(32,'0').match(/.{8}/g).join('.'),
net: i2ip(net), bcast: i2ip(bcast),
first: i2ip(first), last: i2ip(last),
hosts: hosts.toLocaleString('de-DE'),
prefix, klasse, privat,
wildcard: i2ip(~mask>>>0),
});
}
const CIDR_TABLE = [
{pfx:'/8', mask:'255.0.0.0', hosts:'16.777.214', bsp:'Klasse A Großnetz'},
{pfx:'/16',mask:'255.255.0.0',hosts:'65.534',bsp:'Klasse B Mittelnetz'},
{pfx:'/24',mask:'255.255.255.0',hosts:'254',bsp:'Typisches Heimnetz'},
{pfx:'/25',mask:'255.255.255.128',hosts:'126',bsp:'Hälfte eines /24'},
{pfx:'/26',mask:'255.255.255.192',hosts:'62',bsp:'Viertel eines /24'},
{pfx:'/27',mask:'255.255.255.224',hosts:'30',bsp:'Kleines Subnetz'},
{pfx:'/28',mask:'255.255.255.240',hosts:'14',bsp:'VLAN, kleine Abteilung'},
{pfx:'/29',mask:'255.255.255.248',hosts:'6',bsp:'Point-to-Point + Reserve'},
{pfx:'/30',mask:'255.255.255.252',hosts:'2',bsp:'Point-to-Point Link'},
{pfx:'/32',mask:'255.255.255.255',hosts:'1',bsp:'Einzelner Host / Loopback'},
];
const nTabs = [{id:'speed',label:'⚡ Geschwindigkeit'},{id:'subnet',label:'🌐 IP / Subnetz'},{id:'cidr',label:'📋 CIDR-Tabelle'}];
const lbl = {color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:10,marginBottom:4};
const inp = {...S.inp, fontSize:13, padding:'7px 9px', width:'100%', boxSizing:'border-box'};
function NRow({label,value,muted,mono}) {
return (
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'6px 10px',borderRadius:6,background:'rgba(255,255,255,0.03)',marginBottom:3}}>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11}}>{label}</span>
<code style={{color:muted?'rgba(255,255,255,0.45)':'#4ecdc4',
fontFamily:mono===false?'inherit':"'Courier New',monospace",fontSize:12,fontWeight:700,
textAlign:'right',maxWidth:'60%',wordBreak:'break-all'}}>{value}</code>
</div>
);
}
return (
<div>
<div style={{display:'flex',gap:6,marginBottom:18,flexWrap:'wrap'}}>
{nTabs.map(t=>(
<button key={t.id} onClick={()=>setNTab(t.id)} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:nTab===t.id?'#4ecdc4':'rgba(255,255,255,0.05)',
color:nTab===t.id?'#0d0d0f':'rgba(255,255,255,0.65)',
border:nTab===t.id?'none':'1px solid rgba(255,255,255,0.12)',
fontWeight:nTab===t.id?700:400}}>{t.label}</button>
))}
</div>
{nTab==='speed' && (
<div>
<div style={{display:'grid',gridTemplateColumns:mobile?'1fr':'2fr 1fr',gap:12,marginBottom:16}}>
<div>
<div style={lbl}>Wert eingeben</div>
<input style={inp} type="number" min="0" placeholder="z.B. 100"
value={speedVal} onChange={e=>setSpeedVal(e.target.value)}/>
</div>
<div>
<div style={lbl}>Einheit</div>
<select style={{...inp,cursor:'pointer'}} value={speedUnit} onChange={e=>setSpeedUnit(e.target.value)}>
<optgroup label="Bit-basiert">
<option value="bps">bit/s</option>
<option value="kbps">kbit/s</option>
<option value="mbps">Mbit/s</option>
<option value="gbps">Gbit/s</option>
</optgroup>
<optgroup label="Byte-basiert">
<option value="bBps">B/s</option>
<option value="kBps">kB/s</option>
<option value="mBps">MB/s</option>
<option value="gBps">GB/s</option>
</optgroup>
</select>
</div>
</div>
{speedValid && (
<>
<div style={{marginBottom:10}}>
{SPEED_UNITS.map(u=>(
<NRow key={u.id} label={u.label} value={fmtSpeed(speedBps,u)} muted={u.id===speedUnit}/>
))}
</div>
<div style={{marginTop:14,padding:'10px 12px',borderRadius:8,
background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:9,marginBottom:8}}>DOWNLOAD-ZEITEN BEI DIESER GESCHWINDIGKEIT</div>
{[['700 MB CD',700e6],['4,7 GB DVD',4.7e9],['25 GB Blu-ray',25e9],['50 GB Spiel',50e9],['100 GB',100e9]].map(([lbl,b])=>(
<NRow key={lbl} label={lbl} value={dlTime(speedBps,b)} muted/>
))}
<div style={{borderTop:'1px solid rgba(255,255,255,0.07)',marginTop:8,paddingTop:8}}>
<div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap',marginBottom:6}}>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,minWidth:80}}>Eigene Größe</span>
<input style={{...inp,width:90,flex:'none'}} type="number" min="0" placeholder="z.B. 8"
value={customSize} onChange={e=>setCustomSize(e.target.value)}/>
<select style={{...inp,width:70,flex:'none',cursor:'pointer'}} value={customSizeUnit} onChange={e=>setCustomSizeUnit(e.target.value)}>
<option value="kb">kB</option>
<option value="mb">MB</option>
<option value="gb">GB</option>
<option value="tb">TB</option>
</select>
{!!customSize && <span style={{color:'#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:13,fontWeight:700}}>
{dlTime(speedBps, parseFloat(customSize) * ({kb:1e3,mb:1e6,gb:1e9,tb:1e12}[customSizeUnit]||1e6))}
</span>}
</div>
</div>
</div>
<div style={{marginTop:10,padding:'8px 12px',borderRadius:7,
background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.06)'}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,lineHeight:1.8}}>
💡 Faustregel: Mbit/s ÷ 8 = MB/s &nbsp;·&nbsp; Internetanbieter werben mit Mbit/s, Dateimanager zeigt MB/s
</div>
</div>
</>
)}
{!speedValid && (
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,padding:'10px 0'}}>
Geschwindigkeit eingeben alle Einheiten werden berechnet
</div>
)}
</div>
)}
{nTab==='subnet' && (
<div>
<div style={{marginBottom:16}}>
<div style={lbl}>IP-Adresse oder CIDR (z.B. 192.168.1.100/24)</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<input style={{...inp,flex:1,minWidth:200}} placeholder="192.168.1.100/24"
value={ipInput} onChange={e=>calcSubnet(e.target.value)}/>
{!!ipInput && <button style={{...S.btn('#ff6b9d',false),fontSize:11,padding:'7px 14px'}}
onClick={()=>{setIpInput('');setSubnetResult(null);setSubnetErr('');}}></button>}
</div>
{!!subnetErr && <div style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:11,marginTop:6}}>{subnetErr}</div>}
</div>
{subnetResult && (
<div>
<NRow label="IP-Adresse" value={subnetResult.ip}/>
<NRow label="CIDR-Notation" value={subnetResult.cidr}/>
<NRow label="Subnetzmaske" value={subnetResult.mask}/>
<NRow label="Maske binär" value={subnetResult.maskBin}/>
<NRow label="Wildcard-Maske" value={subnetResult.wildcard} muted/>
<NRow label="Netzadresse" value={subnetResult.net}/>
<NRow label="Broadcast" value={subnetResult.bcast}/>
<NRow label="Erster Host" value={subnetResult.first}/>
<NRow label="Letzter Host" value={subnetResult.last}/>
<NRow label="Nutzbare Hosts" value={subnetResult.hosts}/>
<NRow label="IP-Klasse" value={subnetResult.klasse} muted/>
<NRow label="Privater Bereich" value={subnetResult.privat?'✓ Ja (RFC 1918)':'✗ Öffentlich'} muted/>
<div style={{marginTop:10,padding:'8px 12px',borderRadius:7,
background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.06)'}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,lineHeight:1.8}}>
💡 Subnetzmaske: 1er-Bits = Netzanteil, 0er-Bits = Hostanteil &nbsp;·&nbsp;
Broadcast = letzte Adresse &nbsp;·&nbsp; Netz = erste Adresse
</div>
</div>
</div>
)}
{!subnetResult && !subnetErr && (
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,padding:'10px 0'}}>
IP eingeben (mit oder ohne /Präfix) Subnetz wird berechnet · Standard: /24
</div>
)}
</div>
)}
{nTab==='cidr' && (
<div>
<div style={{overflowX:'auto'}}>
<table style={{width:'100%',borderCollapse:'collapse',fontFamily:'monospace',fontSize:12}}>
<thead>
<tr style={{borderBottom:'1px solid rgba(255,255,255,0.1)'}}>
{['Präfix','Subnetzmaske','Nutzbare Hosts','Typischer Einsatz'].map(h=>(
<th key={h} style={{padding:'7px 10px',textAlign:'left',color:'rgba(255,255,255,0.45)',fontSize:10,fontWeight:600}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{CIDR_TABLE.map((r,i)=>(
<tr key={r.pfx} style={{background:i%2?'rgba(255,255,255,0.02)':'transparent',
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<td style={{padding:'7px 10px',color:'#4ecdc4',fontWeight:700}}>{r.pfx}</td>
<td style={{padding:'7px 10px',color:'rgba(255,255,255,0.75)'}}>{r.mask}</td>
<td style={{padding:'7px 10px',color:'rgba(255,255,255,0.75)',textAlign:'right'}}>{r.hosts}</td>
<td style={{padding:'7px 10px',color:'rgba(255,255,255,0.5)',fontSize:11}}>{r.bsp}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginTop:14,padding:'10px 12px',borderRadius:8,
background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:9,marginBottom:6}}>PRIVATE ADRESSBEREICHE (RFC 1918)</div>
{[
['10.0.0.0/8','10.0.0.0 10.255.255.255','Klasse A, große Firmennetzwerke'],
['172.16.0.0/12','172.16.0.0 172.31.255.255','Klasse B, mittlere Netzwerke'],
['192.168.0.0/16','192.168.0.0 192.168.255.255','Klasse C, Heimnetzwerke'],
].map(([cidr,range,desc])=>(
<div key={cidr} style={{display:'flex',gap:12,padding:'4px 0',borderBottom:'1px solid rgba(255,255,255,0.04)',flexWrap:'wrap'}}>
<code style={{color:'#4ecdc4',minWidth:120}}>{cidr}</code>
<span style={{color:'rgba(255,255,255,0.6)',minWidth:200}}>{range}</span>
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11}}>{desc}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────── Regex ──
const RULE_TYPES = [
{ id:'contains', label:'Enthält', fields:['text'], hint:'abc' },
{ id:'notcontains', label:'Enthält nicht', fields:['text'], hint:'abc' },
{ id:'startswith', label:'Beginnt mit', fields:['text'], hint:'KD-' },
{ id:'endswith', label:'Endet mit', fields:['text'], hint:'.pdf' },
{ id:'exact', label:'Ist genau', fields:['text'], hint:'hallo' },
{ id:'digits_n', label:'Genau N Ziffern', fields:['count'], hint:'6' },
{ id:'digits_min', label:'Mindestens N Ziffern', fields:['count'], hint:'3' },
{ id:'digits_range', label:'Ziffern (vonbis)', fields:['min','max'], hint:'' },
{ id:'letters_n', label:'Genau N Buchstaben', fields:['count'], hint:'5' },
{ id:'alnum_n', label:'Genau N Alphanumerisch',fields:['count'], hint:'8' },
{ id:'minlength', label:'Mindestlänge', fields:['count'], hint:'5' },
{ id:'maxlength', label:'Höchstlänge', fields:['count'], hint:'20' },
{ id:'numrange', label:'Zahlenbereich (enthält)', fields:['min','max'], hint:'' },
{ id:'numrange_not', label:'Zahlenbereich (enthält nicht)',fields:['min','max'], hint:'' },
{ id:'numrange_exact',label:'Zahlenbereich (exakt)', fields:['min','max'], hint:'' },
{ id:'onlynums', label:'Nur Zahlen', fields:[], hint:'' },
{ id:'onlyletters', label:'Nur Buchstaben', fields:[], hint:'' },
{ id:'onlyalnum', label:'Nur Alphanumerisch', fields:[], hint:'' },
{ id:'date_de', label:'Datum (TT.MM.JJJJ)', fields:[], hint:'' },
{ id:'date_iso', label:'Datum (JJJJ-MM-TT)', fields:[], hint:'' },
{ id:'time', label:'Uhrzeit (HH:MM)', fields:[], hint:'' },
{ id:'email', label:'E-Mail-Adresse', fields:[], hint:'' },
{ id:'url', label:'URL (http/https)', fields:[], hint:'' },
{ id:'plz', label:'PLZ (5 Ziffern)', fields:[], hint:'' },
{ id:'ipv4', label:'IPv4-Adresse', fields:[], hint:'' },
{ id:'hex_color', label:'Hex-Farbe (#rrggbb)', fields:[], hint:'' },
{ id:'custom', label:'Benutzerdefiniert', fields:['pattern'], hint:'\\d+' },
];
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Build regex fragment + description for a single rule
function ruleToRegex(rule) {
const t = rule.type, f = rule.fields || {};
const esc = escapeRegex(f.text || '');
switch (t) {
case 'contains': return { pat: esc, desc: `enthält "${f.text}"` };
case 'notcontains': return { pat: `(?!.*${esc})`, desc: `enthält NICHT "${f.text}"` };
case 'startswith': return { pat: `^${esc}`, desc: `beginnt mit "${f.text}"` };
case 'endswith': return { pat: `${esc}$`, desc: `endet mit "${f.text}"` };
case 'exact': return { pat: `^${esc}$`, desc: `ist genau "${f.text}"` };
case 'digits_n': return { pat: `\\d{${f.count||1}}`, desc: `genau ${f.count||1} Ziffer(n)` };
case 'digits_min': return { pat: `\\d{${f.count||1},}`, desc: `mindestens ${f.count||1} Ziffer(n)` };
case 'digits_range': return { pat: `\\d{${f.min||1},${f.max||9}}`, desc: `${f.min||1}${f.max||9} Ziffern` };
case 'letters_n': return { pat: `[a-zA-ZäöüÄÖÜß]{${f.count||1}}`, desc: `genau ${f.count||1} Buchstabe(n)` };
case 'alnum_n': return { pat: `[a-zA-Z0-9]{${f.count||1}}`, desc: `genau ${f.count||1} alphanumerische Zeichen` };
case 'minlength': return { pat: `.{${f.count||1},}`, desc: `mindestens ${f.count||1} Zeichen lang` };
case 'maxlength': return { pat: `.{0,${f.count||999}}`, desc: `höchstens ${f.count||999} Zeichen lang` };
case 'numrange': return { pat: `(?:${buildNumRange(Number(f.min||0), Number(f.max||9))})`, desc: `enthält eine Zahl zwischen ${f.min||0} und ${f.max||9}` };
case 'numrange_not': return { pat: `^(?!.*(?:${buildNumRange(Number(f.min||0), Number(f.max||9))})).*$`, desc: `enthält KEINE Zahl zwischen ${f.min||0} und ${f.max||9}` };
case 'numrange_exact': return { pat: `^(?:${buildNumRange(Number(f.min||0), Number(f.max||9))})$`, desc: `ist exakt eine Zahl zwischen ${f.min||0} und ${f.max||9}` };
case 'onlynums': return { pat: `^\\d+$`, desc: `nur Ziffern (09)` };
case 'onlyletters': return { pat: `^[a-zA-ZäöüÄÖÜß]+$`, desc: `nur Buchstaben` };
case 'onlyalnum': return { pat: `^[a-zA-Z0-9]+$`, desc: `nur Buchstaben und Ziffern` };
case 'date_de': return { pat: `^(0[1-9]|[12]\\d|3[01])\\.(0[1-9]|1[0-2])\\.\\d{4}$`, desc: `Datum im Format TT.MM.JJJJ` };
case 'date_iso': return { pat: `^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$`, desc: `Datum im Format JJJJ-MM-TT` };
case 'time': return { pat: `^([01]\\d|2[0-3]):[0-5]\\d$`, desc: `Uhrzeit im Format HH:MM` };
case 'email': return { pat: `^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$`, desc: `gültige E-Mail-Adresse` };
case 'url': return { pat: `^https?:\\/\\/[^\\s/$.?#].[^\\s]*$`, desc: `URL beginnend mit http:// oder https://` };
case 'plz': return { pat: `^\\d{5}$`, desc: `deutsche Postleitzahl (5 Ziffern)` };
case 'ipv4': return { pat: `^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$`, desc: `IPv4-Adresse` };
case 'hex_color': return { pat: `^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`, desc: `Hex-Farbe (#rgb oder #rrggbb)` };
case 'custom': return { pat: f.pattern || '', desc: `benutzerdefiniert: ${f.pattern||''}` };
default: return { pat: '', desc: '' };
}
}
// Simple numeric range pattern builder
function buildNumRange(lo, hi) {
if (isNaN(lo) || isNaN(hi)) return '\\d+';
if (lo === hi) return String(lo);
if (lo > hi) return '\\d+';
if (hi - lo > 9999) return '\\d+'; // too large, fallback
const vals = [];
for (let i = lo; i <= hi; i++) vals.push(String(i));
return vals.join('|');
}
// Combine all rules into final regex
function buildRegex(rules) {
if (!rules.length) return '';
const parts = rules.map(r => ruleToRegex(r).pat).filter(Boolean);
// If any rule has ^ or $ anchors at both ends alone → just combine sequentially
// Wrap in ^ ... $ only if all rules together form a complete match pattern
const hasFullAnchors = parts.some(p => /^\^/.test(p) && /\$$/.test(p));
if (rules.length === 1) return parts[0];
// Multiple rules: concat sequentially (user is building a sequence)
const combined = parts.join('');
return combined;
}
function buildDescription(rules) {
if (!rules.length) return '';
const descs = rules.map(r => ruleToRegex(r).desc).filter(Boolean);
if (descs.length === 1) return `Der Text ${descs[0]}.`;
return `Der Text ${descs.slice(0,-1).join(', dann ')} und ${descs[descs.length-1]}.`;
}
// ── Regex Erklärung (Reverse) ──
function explainRegex(pattern) {
if (!pattern) return [];
const explanations = [];
let p = pattern;
const rules = [
[/^\^/, 'Muss am Anfang beginnen'],
[/\$$/, 'Muss am Ende enden'],
[/\\\./g, 'Literaler Punkt (.)'],
[/\\d\+/g, 'Eine oder mehr Ziffern (09)'],
[/\\d\*/g, 'Beliebig viele Ziffern (09)'],
[/\\d\?/g, 'Optionale Ziffer (09)'],
[/\\d\{(\d+)\}/g, (m,n)=>`Genau ${n} Ziffer(n)`],
[/\\d\{(\d+),(\d+)\}/g, (m,a,b)=>`${a} bis ${b} Ziffern`],
[/\\d\{(\d+),\}/g, (m,n)=>`Mindestens ${n} Ziffer(n)`],
[/\\d/g, 'Eine Ziffer (09)'],
[/\\w\+/g, 'Ein oder mehr Wortzeichen (a-z, A-Z, 0-9, _)'],
[/\\w/g, 'Ein Wortzeichen (a-z, A-Z, 0-9, _)'],
[/\\s/g, 'Ein Leerzeichen/Whitespace'],
[/\\S/g, 'Kein Leerzeichen'],
[/\.\+/g, 'Ein oder mehr beliebige Zeichen'],
[/\.\*/g, 'Beliebig viele beliebige Zeichen (auch keine)'],
[/\./g, 'Ein beliebiges Zeichen'],
[/\[([^\]]+)\]/g, (m,c)=>`Eines der Zeichen: ${c}`],
[/\(([^)]+)\)\?/g, (m,c)=>`Optional: ${c}`],
[/\(([^)]+)\)\+/g, (m,c)=>`Eines oder mehr von: ${c}`],
[/\(([^)]+)\)\*/g, (m,c)=>`Beliebig viele von: ${c}`],
[/\(([^)]+)\)/g, (m,c)=>`Gruppe: ${c}`],
[/\{(\d+)\}/g, (m,n)=>`Genau ${n} Mal wiederholt`],
[/\{(\d+),(\d+)\}/g, (m,a,b)=>`${a} bis ${b} Mal`],
[/\{(\d+),\}/g, (m,n)=>`Mindestens ${n} Mal`],
[/\?/g, 'Optional (0 oder 1 Mal)'],
[/\+/g, '1 oder mehr Mal'],
[/\*/g, '0 oder mehr Mal'],
[/\|/g, 'ODER-Verknüpfung'],
[/\(\?!/g, 'Negativer Lookahead: das Folgende darf NICHT vorkommen'],
[/\(\?=/g, 'Positiver Lookahead: das Folgende muss vorkommen'],
[/\\b/g, 'Wortgrenze'],
[/i$/, 'Groß-/Kleinschreibung ignorieren (Flag: i)'],
[/g$/, 'Alle Treffer suchen (Flag: g)'],
];
// Tokenize step by step
const tokens = [];
let i = 0;
while (i < p.length) {
// Skip anchors
if ((p[i]==='^'||p[i]==='$') && (i===0||i===p.length-1)) {
tokens.push({raw:p[i], label: p[i]==='^'?'Anfang des Textes':'Ende des Textes'});
i++; continue;
}
// Escaped sequences
if (p[i]==='\\' && i+1<p.length) {
const seq = p[i]+p[i+1];
const next2 = p[i+2];
let quant = '';
let qLabel = '';
let j = i+2;
if (next2==='{') {
const end = p.indexOf('}', j);
if (end>j) { quant = p.slice(j,end+1); j=end+1; }
} else if (next2==='+'||next2==='*'||next2==='?') { quant=next2; j=i+3; }
const labels = {'\\d':'Ziffer (09)','\\w':'Wortzeichen','\\s':'Whitespace','\\S':'kein Whitespace','\\b':'Wortgrenze','\\n':'Zeilenumbruch','\\t':'Tab','\\r':'CR','\\+':'Plus (+)','\\*':'Stern (*)','\\?':'Fragezeichen (?)','\\(':'Klammer auf','\\)':'Klammer zu','\\[':'Eckige Klammer [','\\]':'Eckige Klammer ]','\\{':'Geschweifte Klammer {','\\}':'Geschweifte Klammer }','\\^':'Dach (^)','\\$':'Dollar ($)','\\|':'Pipe (|)','\\\\':'Backslash (\\)','\\-':'Bindestrich (-)','\\@':'@-Zeichen','\\.':'Punkt (.)'};
let lbl = labels[seq] || `Zeichen: ${seq}`;
if (quant==='{') lbl += ` — quantifier:${quant}`;
else if (quant) {
const qmap = {'+':'ein oder mehrfach','*':'beliebig oft (auch 0)','?':'optional'};
lbl += ` (${qmap[quant]||quant})`;
// Parse {n} or {n,m}
const qm = quant.match(/^\{(\d+),(\d+)\}$/);
const qm1 = quant.match(/^\{(\d+)\}$/);
const qm2 = quant.match(/^\{(\d+),\}$/);
if (qm) lbl += `${qm[1]} bis ${qm[2]} Mal`;
else if (qm1) lbl += ` — genau ${qm1[1]} Mal`;
else if (qm2) lbl += ` — mindestens ${qm2[1]} Mal`;
}
tokens.push({raw:seq+(quant||''), label:lbl});
i = j; continue;
}
// Character class
if (p[i]==='[') {
const end = p.indexOf(']',i);
if (end>i) {
const cls = p.slice(i,end+1);
const neg = cls[1]==='!';
const inner = cls.slice(neg?2:1,-1);
tokens.push({raw:cls, label:`${neg?'Kein':'Eines der'} Zeichen: ${inner}`});
i = end+1; continue;
}
}
// Group
if (p[i]==='(') {
// find matching )
let depth=1, j=i+1;
while (j<p.length && depth>0) { if(p[j]==='(') depth++; else if(p[j]===')') depth--; j++; }
const group = p.slice(i,j);
let label = `Gruppe`;
if (group.startsWith('(?!')) label = `Negativer Lookahead (darf NICHT folgen)`;
else if (group.startsWith('(?=')) label = `Positiver Lookahead (muss folgen)`;
else if (group.startsWith('(?:')) label = `Nicht-fangende Gruppe`;
tokens.push({raw:group, label});
i=j; continue;
}
// Quantifiers standalone
if (p[i]==='+') { tokens.push({raw:'+', label:'Das Vorherige: 1 oder mehr Mal'}); i++; continue; }
if (p[i]==='*') { tokens.push({raw:'*', label:'Das Vorherige: 0 oder mehr Mal'}); i++; continue; }
if (p[i]==='?') { tokens.push({raw:'?', label:'Das Vorherige: optional'}); i++; continue; }
if (p[i]==='|') { tokens.push({raw:'|', label:'ODER — eine der Alternativen'}); i++; continue; }
if (p[i]==='.') { tokens.push({raw:'.', label:'Ein beliebiges Zeichen (außer Zeilenumbruch)'}); i++; continue; }
// Literal
tokens.push({raw:p[i], label:`Literales Zeichen: "${p[i]}"`}); i++;
}
return tokens;
}
// RuleCard als Top-Level-Komponente (nicht innerhalb von RegexBuilder — vermeidet Fokus-Bug)
const FIELD_LABELS_RC = {text:'Text', count:'Anzahl', min:'Von', max:'Bis', pattern:'Muster'};
const LBL_STYLE = {color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:3};
function RuleCard({rule, idx, onUpdate, onDelete, onMove, inpStyle}) {
const rt = RULE_TYPES.find(r=>r.id===rule.type);
const preview = ruleToRegex(rule);
return (
<div style={{borderRadius:9, background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.09)', padding:'10px 12px', marginBottom:8}}>
<div style={{display:'flex', gap:8, alignItems:'flex-start', flexWrap:'wrap'}}>
{/* Move buttons */}
<div style={{display:'flex', flexDirection:'column', gap:2, paddingTop:2}}>
<button onClick={()=>onMove(rule.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'1px 6px',fontSize:11}}></button>
<button onClick={()=>onMove(rule.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'1px 6px',fontSize:11}}></button>
</div>
{/* Rule number badge */}
<div style={{minWidth:22,height:22,borderRadius:11,background:'rgba(78,205,196,0.15)',
color:'#4ecdc4',fontFamily:'monospace',fontSize:10,fontWeight:700,
display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0,marginTop:2}}>
{idx+1}
</div>
{/* Type select */}
<div style={{flex:'0 0 auto'}}>
<div style={LBL_STYLE}>Regeltyp</div>
<select style={{...inpStyle, width:'auto', cursor:'pointer'}} value={rule.type}
onChange={e=>onUpdate(rule.id,{type:e.target.value, fields:{}})}>
{RULE_TYPES.map(rt=>(
<option key={rt.id} value={rt.id}>{rt.label}</option>
))}
</select>
</div>
{/* Dynamic fields */}
{rt?.fields.map(f=>(
<div key={f} style={{flex:'1 1 80px', minWidth:80}}>
<div style={LBL_STYLE}>{FIELD_LABELS_RC[f]||f}</div>
<input style={{...inpStyle, width:'100%'}}
placeholder={rt.hint||''}
value={rule.fields[f]||''}
onChange={e=>onUpdate(rule.id,{fields:{...rule.fields,[f]:e.target.value}})}/>
</div>
))}
{/* Preview */}
<div style={{flex:'1 1 100px', minWidth:100}}>
<div style={LBL_STYLE}>Regex-Teil</div>
<code style={{display:'block',background:'rgba(0,0,0,0.3)',borderRadius:6,
padding:'7px 9px',color:'#4ecdc4',fontFamily:"'Courier New',monospace",
fontSize:12,wordBreak:'break-all',minHeight:34}}>
{preview.pat || <span style={{color:'rgba(255,255,255,0.2)'}}></span>}
</code>
</div>
{/* Delete */}
<button onClick={()=>onDelete(rule.id)}
style={{background:'rgba(255,107,157,0.12)',border:'1px solid rgba(255,107,157,0.2)',
borderRadius:6,color:'#ff6b9d',cursor:'pointer',padding:'4px 10px',fontSize:13,
marginTop:18,flexShrink:0}}></button>
</div>
<div style={{marginTop:6,marginLeft:60,color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
{preview.desc}
</div>
</div>
);
}
function RegexBuilder({ toast, mobile }) {
const [mode, setMode] = useState('build'); // 'build' | 'explain'
// ── Build mode ──
const [rules, setRules] = useState([]);
const [testVal, setTestVal] = useState('');
const [testExact, setTestExact] = useState(true);
const [copied, setCopied] = useState(false);
function addRule() {
setRules(r => [...r, {id: Date.now(), type: 'contains', fields: {text:''}}]);
}
function updateRule(id, changes) {
setRules(r => r.map(rule => rule.id===id ? {...rule, ...changes} : rule));
}
function deleteRule(id) {
setRules(r => r.filter(rule => rule.id !== id));
}
function moveRule(id, dir) {
setRules(r => {
const idx = r.findIndex(x=>x.id===id);
const next = idx+dir;
if (next<0||next>=r.length) return r;
const arr = [...r];
[arr[idx], arr[next]] = [arr[next], arr[idx]];
return arr;
});
}
const regex = buildRegex(rules);
const description = buildDescription(rules);
let testResult = null;
if (regex && testVal !== '') {
try {
const pat = testExact ? `^(?:${regex})$` : regex;
testResult = new RegExp(pat).test(testVal);
} catch(e) { testResult = null; }
}
async function copyRegex() {
if (!regex) return;
try { await navigator.clipboard.writeText(regex); setCopied(true); setTimeout(()=>setCopied(false),1800); }
catch { toast('Kopieren fehlgeschlagen'); }
}
// ── Explain mode ──
const [explainInput, setExplainInput] = useState('');
const [explainTestVal, setExplainTestVal] = useState('');
const [explainExact, setExplainExact] = useState(false);
const explained = explainInput ? explainRegex(explainInput) : [];
let explainTestResult = null;
if (explainInput && explainTestVal !== '') {
try {
const pat = explainExact ? `^(?:${explainInput})$` : explainInput;
explainTestResult = new RegExp(pat).test(explainTestVal);
} catch {}
}
const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'};
const lbl = {color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:3};
return (
<div>
{/* Mode tabs */}
<div style={{display:'flex',gap:6,marginBottom:20}}>
{[['build','🔨 Regex bauen'],['explain','🔍 Regex erklären']].map(([id,tabLbl])=>(
<button key={id} onClick={()=>setMode(id)} style={{
padding:'7px 18px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:mode===id?'#4ecdc4':'rgba(255,255,255,0.05)',
color:mode===id?'#0d0d0f':'rgba(255,255,255,0.65)',
border:mode===id?'none':'1px solid rgba(255,255,255,0.12)',
fontWeight:mode===id?700:400}}>{tabLbl}</button>
))}
</div>
{mode==='build' && (
<div>
{/* Regel-Karten */}
{rules.map((rule,idx)=>(
<RuleCard key={rule.id} rule={rule} idx={idx}
onUpdate={updateRule} onDelete={deleteRule} onMove={moveRule} inpStyle={inp}/>
))}
<button style={{...S.btn('#4ecdc4',false), fontSize:12, padding:'8px 18px', marginBottom:20}}
onClick={addRule}>+ Regel hinzufügen</button>
{!!rules.length && (
<>
{/* Regex-Ausgabe */}
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.08)',padding:'12px 14px',marginBottom:12}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8}}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>GENERIERTER REGEX</span>
<button onClick={copyRegex} style={{
background:'rgba(78,205,196,0.1)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:6,color:copied?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',padding:'2px 10px',fontSize:11}}>
{copied?'✓ Kopiert':'⎘ Kopieren'}
</button>
</div>
<code style={{display:'block',color:'#4ecdc4',fontFamily:"'Courier New',monospace",
fontSize:15,fontWeight:700,wordBreak:'break-all',lineHeight:1.5}}>
{regex || <span style={{color:'rgba(255,255,255,0.2)'}}></span>}
</code>
{!!description && (
<div style={{marginTop:8,color:'rgba(255,255,255,0.6)',fontSize:12,lineHeight:1.6}}>
{description}
</div>
)}
</div>
{/* Test-Bereich */}
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.08)',padding:'12px 14px'}}>
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:8,flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>TESTEN</span>
<label style={{display:'flex',alignItems:'center',gap:6,cursor:'pointer'}}>
<input type="checkbox" checked={testExact} onChange={e=>setTestExact(e.target.checked)}/>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10}}>
Exakt (^$) ganzer Text muss passen
</span>
</label>
</div>
<div style={{display:'flex',gap:10,alignItems:'center',flexWrap:'wrap'}}>
<input style={{...inp,flex:1,minWidth:160}} placeholder="Testwert eingeben…"
value={testVal} onChange={e=>setTestVal(e.target.value)}/>
{testResult===true && <span style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:14,fontWeight:700}}> Treffer</span>}
{testResult===false && <span style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:14,fontWeight:700}}> Kein Treffer</span>}
</div>
</div>
</>
)}
{!rules.length && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,
padding:'16px 0',textAlign:'center'}}>
Noch keine Regeln klicke + Regel hinzufügen"
</div>
)}
</div>
)}
{mode==='explain' && (
<div>
<div style={{marginBottom:14}}>
<div style={{...lbl,fontSize:11,marginBottom:6}}>Regex eingeben</div>
<input style={{...inp,width:'100%',boxSizing:'border-box',fontSize:14,fontFamily:"'Courier New',monospace"}}
placeholder="z.B. ^[a-zA-Z0-9]{6,20}$"
value={explainInput} onChange={e=>setExplainInput(e.target.value)}/>
</div>
{!!explained.length && (
<>
<div style={{marginBottom:14}}>
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>ERKLÄRUNG BAUSTEIN FÜR BAUSTEIN</div>
{explained.map((tok,i)=>(
<div key={i} style={{display:'flex',alignItems:'center',gap:10,padding:'6px 10px',
borderRadius:7,background:'rgba(255,255,255,0.03)',marginBottom:4,
border:'1px solid rgba(255,255,255,0.06)'}}>
<code style={{color:'#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:13,
fontWeight:700,minWidth:60,flexShrink:0}}>{tok.raw}</code>
<span style={{color:'rgba(255,255,255,0.7)',fontSize:12}}>{tok.label}</span>
</div>
))}
</div>
{/* Test */}
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.08)',padding:'12px 14px'}}>
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:8,flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>TESTEN</span>
<label style={{display:'flex',alignItems:'center',gap:6,cursor:'pointer'}}>
<input type="checkbox" checked={explainExact} onChange={e=>setExplainExact(e.target.checked)}/>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10}}>
Exakt (^…$)
</span>
</label>
</div>
<div style={{display:'flex',gap:10,alignItems:'center',flexWrap:'wrap'}}>
<input style={{...inp,flex:1,minWidth:160}} placeholder="Testwert eingeben"
value={explainTestVal} onChange={e=>setExplainTestVal(e.target.value)}/>
{explainTestResult===true && <span style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:14,fontWeight:700}}>✓ Treffer</span>}
{explainTestResult===false && <span style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:14,fontWeight:700}}>✗ Kein Treffer</span>}
</div>
</div>
</>
)}
{!explainInput && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'16px 0',textAlign:'center'}}>
Einen bestehenden Regex oben eingeben → jeder Baustein wird erklärt
</div>
)}
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────── JSON Tool ──
// ──────────────────────────────────────────────────────────── JSON / XML Tool ──
// Typografische Anführungszeichen und andere häufige Probleme beheben
function sanitizeJson(raw) {
let s = raw
// Alle typografischen/deutschen Anführungszeichen → gerade "
.replace(/[\u201C\u201D\u201E\u201F\u00AB\u00BB\u2039\u203A]/g, '"')
// Einfache typografische → gerade "
.replace(/[\u2018\u2019\u02BC\u0060]/g, '"')
// Trailing commas vor } oder ]
.replace(/,\s*([}\]])/g, '$1')
// Unquoted keys: {key: ...} → {"key": ...}
.replace(/([{,]\s*)([a-zA-Z_\u00C0-\u024F][a-zA-Z0-9_\u00C0-\u024F]*)\s*:/g, '$1"$2":');
const trimmed = s.trim();
// Falls kein vollständiges JSON-Objekt/Array → versuche als Objekt-Fragment wrappen
// z.B. "Mitarbeiter": [...] → {"Mitarbeiter": [...]}
if (trimmed && !trimmed.startsWith('{') && !trimmed.startsWith('[')
&& !trimmed.startsWith('"') && trimmed !== 'null'
&& trimmed !== 'true' && trimmed !== 'false') {
s = '{' + trimmed + '}';
} else if (trimmed && trimmed.startsWith('"') && trimmed.includes(':')) {
// Quoted key at root level: "key": value → {"key": value}
s = '{' + trimmed + '}';
}
return s;
}
// JSON → XML string
function jsonToXml(data, tag = 'root', indent = 0) {
const pad = ' '.repeat(indent);
if (data === null) return `${pad}<${tag} null="true"/>`;
if (typeof data !== 'object') {
return `${pad}<${tag}>${String(data).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</${tag}>`;
}
if (Array.isArray(data)) {
return data.map((v,i) => jsonToXml(v, 'item', indent)).join('\n');
}
const children = Object.entries(data)
.map(([k,v]) => jsonToXml(v, k.replace(/\s+/g,'_'), indent+1))
.join('\n');
return `${pad}<${tag}>\n${children}\n${pad}</${tag}>`;
}
// XML → JSON object (basic parser)
function xmlToJson(xml) {
const parser = new DOMParser();
const doc = parser.parseFromString(xml.trim(), 'application/xml');
const err = doc.querySelector('parsererror');
if (err) throw new Error('Ungültiges XML: ' + err.textContent.split('\n')[0]);
function nodeToObj(node) {
if (node.nodeType === 3) { // text
const t = node.textContent.trim();
return t === '' ? null : t;
}
if (node.childNodes.length === 0) return null;
if (node.childNodes.length === 1 && node.firstChild.nodeType === 3) {
return node.firstChild.textContent;
}
const obj = {};
const arr = {};
node.childNodes.forEach(child => {
if (child.nodeType !== 1) return;
const val = nodeToObj(child);
const name = child.nodeName;
if (name in arr) { arr[name]++; } else { arr[name] = 1; }
});
// build result
const seen = {};
node.childNodes.forEach(child => {
if (child.nodeType !== 1) return;
const name = child.nodeName;
const val = nodeToObj(child);
if (arr[name] > 1) {
if (!obj[name]) obj[name] = [];
obj[name].push(val);
} else {
obj[name] = val;
}
});
return obj;
}
return nodeToObj(doc.documentElement);
}
// ── JSON Viewer ──
function JsonNode({ data, depth, keyName }) {
const [open, setOpen] = useState(depth < 2);
const indent = depth * 16;
const isObj = data !== null && typeof data === 'object' && !Array.isArray(data);
const isArr = Array.isArray(data);
const isPrim = !isObj && !isArr;
const typeColor = { string:'#a8f0eb', number:'#ffe66d', boolean:'#ff6b9d', null:'rgba(255,255,255,0.3)' };
function valColor(v) { if (v===null) return typeColor.null; return typeColor[typeof v]||'#fff'; }
function valLabel(v) { if (v===null) return 'null'; if (typeof v==='string') return `"${v}"`; return String(v); }
function typeTag(v) {
if (v===null) return 'null';
if (Array.isArray(v)) return `Array[${v.length}]`;
if (typeof v==='object') return `Objekt {${Object.keys(v).length} Felder}`;
return typeof v;
}
const keyStyle = {color:'#7eb8ff', fontFamily:"'Courier New',monospace", fontSize:12};
const lineBase = {display:'flex', alignItems:'baseline', gap:4, padding:'1px 0', marginLeft:indent, lineHeight:1.7};
if (isPrim) return (
<div style={lineBase}>
{keyName!==undefined && <><span style={keyStyle}>"{keyName}"</span><span style={{color:'rgba(255,255,255,0.3)'}}>:</span></>}
<span style={{color:valColor(data), fontFamily:"'Courier New',monospace", fontSize:12}}>{valLabel(data)}</span>
<span style={{color:'rgba(255,255,255,0.2)', fontSize:10, marginLeft:4}}>{typeof data==='object'?'null':typeof data}</span>
</div>
);
const entries = isArr ? data.map((v,i)=>[i,v]) : Object.entries(data);
const [o,c] = isArr ? ['[',']'] : ['{','}'];
return (
<div>
<div style={{...lineBase, cursor:'pointer', userSelect:'none'}} onClick={()=>setOpen(x=>!x)}>
<span style={{color:'rgba(255,255,255,0.3)', fontSize:11, minWidth:14}}>{open?'▾':'▸'}</span>
{keyName!==undefined && <><span style={keyStyle}>"{keyName}"</span><span style={{color:'rgba(255,255,255,0.3)'}}>:</span></>}
<span style={{color:'rgba(255,255,255,0.5)', fontFamily:"'Courier New',monospace", fontSize:12}}>{o}</span>
{!open && <span style={{color:'rgba(255,255,255,0.25)', fontFamily:"'Courier New',monospace", fontSize:11}}>{isArr?`${entries.length} Einträge`:`${entries.length} Felder`}</span>}
{!open && <span style={{color:'rgba(255,255,255,0.5)', fontFamily:"'Courier New',monospace", fontSize:12}}>{c}</span>}
<span style={{color:'rgba(255,255,255,0.2)', fontSize:10, marginLeft:4}}>{typeTag(data)}</span>
</div>
{open && <>
{entries.map(([k,v]) => <JsonNode key={k} data={v} depth={depth+1} keyName={k}/>)}
<div style={{marginLeft:indent, color:'rgba(255,255,255,0.5)', fontFamily:"'Courier New',monospace", fontSize:12}}>{c}</div>
</>}
</div>
);
}
// ── JSON Builder ──
let _nid = 1;
function newNode(type='string') { return {id:_nid++, type, key:'', value:'', children:[]}; }
function nodeToJson(node) {
if (node.type==='object') { const o={}; node.children.forEach(c=>{o[c.key||`feld${c.id}`]=nodeToJson(c);}); return o; }
if (node.type==='array') { return node.children.map(c=>nodeToJson(c)); }
if (node.type==='number') return isNaN(Number(node.value))?0:Number(node.value);
if (node.type==='boolean') return node.value!=='false';
if (node.type==='null') return null;
return node.value;
}
const TC = {object:'#4ecdc4', array:'#ffe66d', string:'#a8f0eb', number:'#ff6b9d', boolean:'#c3b1e1', null:'rgba(255,255,255,0.4)'};
function BuilderNode({node, depth, onChange, onDelete}) {
const inp = {...S.inp, fontSize:12, padding:'4px 7px', boxSizing:'border-box'};
const canKids = node.type==='object'||node.type==='array';
const isRoot = depth===0;
function addChild(type) { onChange({...node, children:[...node.children, newNode(type)]}); }
function updChild(u) { onChange({...node, children:node.children.map(c=>c.id===u.id?u:c)}); }
function delChild(id) { onChange({...node, children:node.children.filter(c=>c.id!==id)}); }
return (
<div style={{marginLeft:depth*18, marginBottom:4}}>
<div style={{display:'flex', alignItems:'center', gap:6, flexWrap:'wrap'}}>
{!isRoot && (
<input style={{...inp, width:90, color:'#7eb8ff'}} placeholder="Key / Name"
value={node.key} onChange={e=>onChange({...node, key:e.target.value})}/>
)}
{!isRoot && <span style={{color:'rgba(255,255,255,0.25)', fontSize:11}}>:</span>}
<select style={{...inp, width:'auto', color:TC[node.type], cursor:'pointer'}}
value={node.type} onChange={e=>onChange({...node, type:e.target.value, children:[], value:''})}>
<option value="string">Text (string)</option>
<option value="number">Zahl (number)</option>
<option value="boolean">Ja/Nein (boolean)</option>
<option value="null">Leer (null)</option>
<option value="object">Objekt {'{ }'}</option>
<option value="array">Liste [ ]</option>
</select>
{node.type==='string' && <input style={{...inp,flex:1,minWidth:80}} placeholder="Wert eingeben" value={node.value} onChange={e=>onChange({...node,value:e.target.value})}/>}
{node.type==='number' && <input style={{...inp,width:100}} type="number" placeholder="0" value={node.value} onChange={e=>onChange({...node,value:e.target.value})}/>}
{node.type==='boolean' && <select style={{...inp,width:'auto',cursor:'pointer'}} value={node.value} onChange={e=>onChange({...node,value:e.target.value})}><option value="true">true (ja)</option><option value="false">false (nein)</option></select>}
{node.type==='null' && <span style={{color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace'}}>null (kein Wert)</span>}
{canKids && (
<select style={{...inp,width:'auto',cursor:'pointer',color:'#4ecdc4'}} value="" onChange={e=>{if(e.target.value)addChild(e.target.value);}}>
<option value="">+ Feld hinzufügen</option>
<option value="string">Text</option>
<option value="number">Zahl</option>
<option value="boolean">Ja/Nein</option>
<option value="null">Leer (null)</option>
<option value="object">Objekt</option>
<option value="array">Liste</option>
</select>
)}
{!isRoot && <button onClick={onDelete} style={{background:'rgba(255,107,157,0.12)',border:'none',borderRadius:5,color:'#ff6b9d',cursor:'pointer',padding:'3px 8px',fontSize:12}}></button>}
</div>
{canKids && node.children.map(child=>(
<BuilderNode key={child.id} node={child} depth={depth+1} onChange={updChild} onDelete={()=>delChild(child.id)}/>
))}
</div>
);
}
// ── Main Tool ──
function JsonTool({ toast, mobile }) {
const [tab, setTab] = useState('view');
const [fmt, setFmt] = useState('json'); // 'json' | 'xml'
// View / Convert
const [rawInput, setRawInput] = useState('');
const [parsed, setParsed] = useState(null);
const [parseErr, setParseErr] = useState('');
const [xmlOut, setXmlOut] = useState('');
const [copiedOut, setCopiedOut] = useState(false);
function doParseJson(val) {
setRawInput(val);
setParseErr(''); setParsed(null); setXmlOut('');
if (!val.trim()) return;
const fixed = sanitizeJson(val);
try {
const p = JSON.parse(fixed);
setParsed(p);
setXmlOut(jsonToXml(p));
} catch(e) {
setParseErr(e.message);
}
}
function doParseXml(val) {
setRawInput(val);
setParseErr(''); setParsed(null); setXmlOut('');
if (!val.trim()) return;
try {
const obj = xmlToJson(val);
setParsed(obj);
setXmlOut(val);
} catch(e) {
setParseErr(e.message);
}
}
function handleInput(val) {
if (fmt==='json') doParseJson(val);
else doParseXml(val);
}
function formatJson() {
if (!parsed) return;
setRawInput(JSON.stringify(parsed, null, 2));
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); setCopiedOut(true); setTimeout(()=>setCopiedOut(false),1800); }
catch { toast('Kopieren fehlgeschlagen'); }
}
// Builder
const [root, setRoot] = useState(() => ({...newNode('object'), key:'root'}));
const [buildFmt, setBuildFmt] = useState('json');
const [copiedBld, setCopiedBld] = useState(false);
const builtJson = JSON.stringify(nodeToJson(root), null, 2);
const builtXml = (() => { try { return jsonToXml(nodeToJson(root)); } catch { return ''; } })();
const builtOut = buildFmt==='json' ? builtJson : builtXml;
async function copyBuilt() {
try { await navigator.clipboard.writeText(builtOut); setCopiedBld(true); setTimeout(()=>setCopiedBld(false),1800); }
catch { toast('Kopieren fehlgeschlagen'); }
}
const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'};
const tabs = [{id:'view',label:'👁 Ansehen & Konvertieren'},{id:'build',label:'🔨 Struktur bauen'}];
const FmtToggle = ({value, onChange}) => (
<div style={{display:'flex',gap:4}}>
{['json','xml'].map(f=>(
<button key={f} onClick={()=>onChange(f)} style={{
padding:'4px 12px',borderRadius:16,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:value===f?'#4ecdc4':'rgba(255,255,255,0.05)',
color:value===f?'#0d0d0f':'rgba(255,255,255,0.55)',
border:value===f?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight:value===f?700:400}}>{f.toUpperCase()}</button>
))}
</div>
);
return (
<div>
<div style={{display:'flex',gap:6,marginBottom:18,flexWrap:'wrap'}}>
{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>
))}
</div>
{tab==='view' && (
<div>
{/* Anleitung */}
<div style={{padding:'10px 12px',borderRadius:8,background:'rgba(78,205,196,0.06)',
border:'1px solid rgba(78,205,196,0.15)',marginBottom:14}}>
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,fontWeight:700,marginBottom:6}}>SO FUNKTIONIERT ES</div>
<div style={{color:'rgba(255,255,255,0.65)',fontSize:12,lineHeight:1.8}}>
JSON oder XML einfügen der Baum zeigt die Struktur auf/zuklappbar. Pfeile anklicken.<br/>
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11}}>
Typografische Anführungszeichen " werden automatisch korrigiert. Unvollständige Fragmente werden soweit möglich automatisch ergänzt.
</span>
</div>
<div style={{marginTop:10,display:'grid',gridTemplateColumns:mobile?'1fr':'1fr 1fr',gap:8}}>
{[
['Objekt { }','Zusammengehörige Felder eines Eintrags.\nBeispiel: eine Person mit Name und Alter.',
'{\n "name": "Max",\n "alter": 30\n}'],
['Array [ ]','Eine Liste gleichartiger Einträge.\nBeispiel: mehrere Personen.',
'[\n {"name": "Max"},\n {"name": "Lisa"}\n]'],
].map(([title,desc,ex])=>(
<div key={title} style={{background:'rgba(0,0,0,0.2)',borderRadius:7,padding:'8px 10px'}}>
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:11,fontWeight:700,marginBottom:3}}>{title}</div>
<div style={{color:'rgba(255,255,255,0.55)',fontSize:11,lineHeight:1.6,marginBottom:6,whiteSpace:'pre-line'}}>{desc}</div>
<pre style={{margin:0,color:'rgba(255,255,255,0.7)',fontFamily:"'Courier New',monospace",fontSize:10,lineHeight:1.5}}>{ex}</pre>
</div>
))}
</div>
</div>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8,flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10}}>EINGABE-FORMAT:</span>
<FmtToggle value={fmt} onChange={f=>{setFmt(f);setRawInput('');setParsed(null);setParseErr('');setXmlOut('');}}/>
{!!rawInput && <button style={{...S.btn('#ff6b9d',false),fontSize:10,padding:'4px 12px'}} onClick={()=>{setRawInput('');setParsed(null);setParseErr('');setXmlOut('');}}>✕ Leeren</button>}
</div>
<textarea style={{...inp, width:'100%', minHeight:130, fontFamily:"'Courier New',monospace",
fontSize:12, resize:'vertical', lineHeight:1.5,
border: parseErr?'1px solid rgba(255,107,157,0.5)':undefined}}
placeholder={fmt==='json'
? '{\n "name": "Max",\n "alter": 30,\n "aktiv": true\n}'
: '<person>\n <name>Max</name>\n <alter>30</alter>\n</person>'}
value={rawInput} onChange={e=>handleInput(e.target.value)}/>
{!!parseErr && <div style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:11,marginTop:4,padding:'6px 10px',background:'rgba(255,107,157,0.08)',borderRadius:6}}>
⚠ {parseErr}
</div>}
{!!rawInput && !parseErr && fmt==='json' && (
<button style={{...S.btn('#4ecdc4',false),fontSize:11,padding:'5px 14px',marginTop:8}}
onClick={formatJson}>⟳ Formatieren / Einrücken</button>
)}
{parsed !== null && (
<>
{/* Interaktiver Baum */}
<div style={{marginTop:14,borderRadius:9,background:'rgba(0,0,0,0.2)',
border:'1px solid rgba(255,255,255,0.08)',padding:'10px 14px',overflowX:'auto',marginBottom:14}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>
STRUKTUR — Pfeile anklicken zum Auf-/Zuklappen
</div>
<JsonNode data={parsed} depth={0}/>
</div>
{/* Konvertierung */}
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.08)',padding:'12px 14px'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8,flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
KONVERTIERT ALS {fmt==='json'?'XML':'JSON'}
</span>
<button onClick={()=>copyText(fmt==='json'?xmlOut:JSON.stringify(parsed,null,2))} style={{
background:'rgba(78,205,196,0.1)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:6,color:copiedOut?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',padding:'2px 10px',fontSize:11}}>
{copiedOut?'✓ Kopiert':'⎘ Kopieren'}
</button>
</div>
<pre style={{margin:0,color:'rgba(255,255,255,0.75)',fontFamily:"'Courier New',monospace",
fontSize:12,lineHeight:1.6,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-word',
maxHeight:300}}>
{fmt==='json' ? xmlOut : JSON.stringify(parsed,null,2)}
</pre>
</div>
</>
)}
{!rawInput && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,
padding:'16px 0',textAlign:'center'}}>
{fmt==='json'?'JSON':'XML'} oben einfügen → interaktiver Baum + Konvertierung erscheint
</div>
)}
</div>
)}
{tab==='build' && (
<div>
{/* Anleitung */}
<div style={{padding:'10px 12px',borderRadius:8,background:'rgba(78,205,196,0.06)',
border:'1px solid rgba(78,205,196,0.15)',marginBottom:14}}>
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,fontWeight:700,marginBottom:6}}>SO BAUST DU EINE STRUKTUR</div>
<div style={{color:'rgba(255,255,255,0.65)',fontSize:12,lineHeight:1.8,marginBottom:10}}>
Klicke <strong style={{color:'#4ecdc4'}}>„+ Feld hinzufügen"</strong> und wähle einen Typ. Felder können beliebig tief verschachtelt werden.
</div>
<div style={{display:'grid',gridTemplateColumns:mobile?'1fr':'1fr 1fr 1fr',gap:8,marginBottom:10}}>
{[
['Text (string)','#a8f0eb','Normaler Text.\nz.B. Name, Adresse, E-Mail','„Max Mustermann"'],
['Zahl (number)','#ffe66d','Eine Zahl.\nz.B. Alter, Preis, Menge','42 oder 3.14'],
['Ja/Nein (boolean)','#ff6b9d','Wahrheitswert.\nz.B. aktiv, sichtbar','true oder false'],
['Objekt { }','#4ecdc4','Zusammengehörige Felder.\nz.B. eine Person mit Name + Alter','{ name, alter }'],
['Liste [ ]','#ffe66d','Mehrere gleichartige Einträge.\nz.B. eine Mitarbeiterliste','[ {...}, {...} ]'],
['Leer (null)','rgba(255,255,255,0.4)','Kein Wert vorhanden.\nz.B. optionales Feld','null'],
].map(([title,color,desc,ex])=>(
<div key={title} style={{background:'rgba(0,0,0,0.2)',borderRadius:7,padding:'7px 9px'}}>
<div style={{color,fontFamily:'monospace',fontSize:10,fontWeight:700,marginBottom:2}}>{title}</div>
<div style={{color:'rgba(255,255,255,0.5)',fontSize:11,lineHeight:1.5,whiteSpace:'pre-line',marginBottom:3}}>{desc}</div>
<code style={{color:'rgba(255,255,255,0.35)',fontSize:10}}>{ex}</code>
</div>
))}
</div>
<div style={{color:'rgba(255,255,255,0.4)',fontSize:11,lineHeight:1.6}}>
💡 Beispiel Mitarbeiterliste": Root-Objekt → Feld „Mitarbeiter" als <span style={{color:'#ffe66d'}}>Liste [ ]</span> in der Liste Einträge als <span style={{color:'#4ecdc4'}}>Objekt {'{ }'}</span> mit Feldern vorname" und nachname" als <span style={{color:'#a8f0eb'}}>Text</span>.
</div>
</div>
<div style={{borderRadius:9,background:'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.08)',padding:'12px 14px',marginBottom:14,overflowX:'auto'}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:10}}>
STRUKTUR AUFBAUEN Felder hinzufügen, benennen, verschachteln
</div>
<BuilderNode node={root} depth={0} onChange={setRoot} onDelete={()=>{}}/>
</div>
<div style={{borderRadius:9,background:'rgba(0,0,0,0.2)',
border:'1px solid rgba(255,255,255,0.08)',padding:'12px 14px'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8,flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>AUSGABE:</span>
<FmtToggle value={buildFmt} onChange={setBuildFmt}/>
<button onClick={copyBuilt} style={{
background:'rgba(78,205,196,0.1)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:6,color:copiedBld?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',padding:'2px 10px',fontSize:11}}>
{copiedBld?'✓ Kopiert':'⎘ Kopieren'}
</button>
<button onClick={()=>{setTab('view');setFmt('json');doParseJson(builtJson);}} style={{
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:6,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'2px 10px',fontSize:11}}>
👁 Im Viewer öffnen
</button>
</div>
<pre style={{margin:0,color:'rgba(255,255,255,0.8)',fontFamily:"'Courier New',monospace",
fontSize:12,lineHeight:1.6,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-word',
maxHeight:400}}>
{builtOut}
</pre>
</div>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────── Farb-Rechner ──
// ── Konvertierungsfunktionen ──
function hexToRgb(hex) {
hex = hex.replace(/^#/, '');
if (hex.length === 3) hex = hex.split('').map(c=>c+c).join('');
if (hex.length === 4) hex = hex.split('').map(c=>c+c).join(''); // #rgba
const n = parseInt(hex.slice(0,6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6,8),16)/255 : 1;
return { r:(n>>16)&255, g:(n>>8)&255, b:n&255, a };
}
function rgbToHex({r,g,b,a=1}) {
const h = [r,g,b].map(v=>Math.round(v).toString(16).padStart(2,'0')).join('');
const ah = a < 1 ? Math.round(a*255).toString(16).padStart(2,'0') : '';
return '#' + h + ah;
}
function rgbToHsl({r,g,b,a=1}) {
r/=255; g/=255; b/=255;
const max=Math.max(r,g,b), min=Math.min(r,g,b);
let h=0,s=0,l=(max+min)/2;
if (max!==min) {
const d=max-min;
s=l>0.5?d/(2-max-min):d/(max+min);
switch(max){
case r: h=((g-b)/d+(g<b?6:0))/6; break;
case g: h=((b-r)/d+2)/6; break;
case b: h=((r-g)/d+4)/6; break;
}
}
return { h:Math.round(h*360), s:Math.round(s*100), l:Math.round(l*100), a };
}
function hslToRgb({h,s,l,a=1}) {
s/=100; l/=100;
const k=n=>(n+h/30)%12;
const c=s*Math.min(l,1-l);
const f=n=>l-c*Math.max(-1,Math.min(k(n)-3,Math.min(9-k(n),1)));
return {r:Math.round(f(0)*255), g:Math.round(f(8)*255), b:Math.round(f(4)*255), a};
}
function rgbToHsv({r,g,b,a=1}) {
r/=255; g/=255; b/=255;
const max=Math.max(r,g,b), min=Math.min(r,g,b), d=max-min;
let h=0;
if (d!==0){
switch(max){
case r: h=((g-b)/d)%6; break;
case g: h=(b-r)/d+2; break;
case b: h=(r-g)/d+4; break;
}
h=Math.round(h*60); if(h<0) h+=360;
}
return {h, s:max===0?0:Math.round(d/max*100), v:Math.round(max*100), a};
}
function rgbToCmyk({r,g,b}) {
r/=255; g/=255; b/=255;
const k=1-Math.max(r,g,b);
if (k===1) return {c:0,m:0,y:0,k:100};
return {
c:Math.round((1-r-k)/(1-k)*100),
m:Math.round((1-g-k)/(1-k)*100),
y:Math.round((1-b-k)/(1-k)*100),
k:Math.round(k*100)
};
}
// Relative luminance (WCAG)
function luminance({r,g,b}) {
const c = [r,g,b].map(v => {
v/=255;
return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4);
});
return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2];
}
function contrastRatio(lum1, lum2) {
const [l,d] = lum1>lum2 ? [lum1,lum2] : [lum2,lum1];
return (l+0.05)/(d+0.05);
}
// Parse any color string input → {r,g,b,a} or null
function parseColor(raw) {
if (!raw) return null;
raw = raw.trim();
// hex
if (/^#?[0-9a-fA-F]{3,8}$/.test(raw)) {
try { return hexToRgb(raw.startsWith('#')?raw:'#'+raw); } catch {}
}
// rgb / rgba
const rgbM = raw.match(/rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)(?:\s*[,\/]\s*([\d.]+%?))?\s*\)/i);
if (rgbM) {
const a = rgbM[4] ? (rgbM[4].includes('%') ? parseFloat(rgbM[4])/100 : parseFloat(rgbM[4])) : 1;
return {r:+rgbM[1],g:+rgbM[2],b:+rgbM[3],a};
}
// hsl / hsla
const hslM = raw.match(/hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?(?:\s*[,\/]\s*([\d.]+%?))?\s*\)/i);
if (hslM) {
const a = hslM[4] ? (hslM[4].includes('%')?parseFloat(hslM[4])/100:parseFloat(hslM[4])) : 1;
return hslToRgb({h:+hslM[1],s:+hslM[2],l:+hslM[3],a});
}
// CSS color names (basic set)
const NAMES = {red:[255,0,0],green:[0,128,0],blue:[0,0,255],white:[255,255,255],black:[0,0,0],
yellow:[255,255,0],cyan:[0,255,255],magenta:[255,0,255],orange:[255,165,0],purple:[128,0,128],
pink:[255,192,203],gray:[128,128,128],grey:[128,128,128],lime:[0,255,0],navy:[0,0,128],
teal:[0,128,128],silver:[192,192,192],maroon:[128,0,0],olive:[128,128,0]};
const name = NAMES[raw.toLowerCase()];
if (name) return {r:name[0],g:name[1],b:name[2],a:1};
return null;
}
function fmt2(n) { return Math.round(n*100)/100; }
function FarbRechner({ toast, mobile }) {
const [input, setInput] = useState('');
const [pickerHex,setPickerHex]= useState('#4ecdc4');
const [color, setColor] = useState(null); // {r,g,b,a}
const [copied, setCopied] = useState('');
function applyColor(rgba) {
setColor(rgba);
// sync picker (ignore alpha for picker)
setPickerHex(rgbToHex({...rgba,a:1}));
}
function handleInput(val) {
setInput(val);
const c = parseColor(val);
if (c) applyColor(c);
else if (!val.trim()) setColor(null);
}
function handlePicker(hex) {
setPickerHex(hex);
const c = hexToRgb(hex);
// preserve alpha from current color
const a = color?.a ?? 1;
setInput(hex);
applyColor({...c, a});
setInput(hex);
}
async function copy(text, key) {
try { await navigator.clipboard.writeText(text); setCopied(key); setTimeout(()=>setCopied(''),1800); }
catch { toast('Kopieren fehlgeschlagen'); }
}
const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'};
// Derived formats
const formats = color ? (() => {
const {r,g,b,a} = color;
const hsl = rgbToHsl(color);
const hsv = rgbToHsv(color);
const cmyk = rgbToCmyk(color);
const hex6 = rgbToHex({r,g,b,a:1});
const hex8 = rgbToHex({r,g,b,a});
const hasAlpha = a < 1;
const lum = luminance({r,g,b});
const contrastWhite = fmt2(contrastRatio(lum, 1));
const contrastBlack = fmt2(contrastRatio(lum, 0));
const wcagWhite = contrastWhite>=7?'AAA':contrastWhite>=4.5?'AA':contrastWhite>=3?'AA (groß)':'✗';
const wcagBlack = contrastBlack>=7?'AAA':contrastBlack>=4.5?'AA':contrastBlack>=3?'AA (groß)':'✗';
const textColor = lum > 0.179 ? '#000' : '#fff';
return [
{key:'hex6', label:'HEX', value: hex6, copy: hex6},
{key:'hex8', label:'HEX + Alpha', value: hex8, copy: hex8, hide: !hasAlpha},
{key:'rgb', label:'RGB', value: `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`, copy: `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`},
{key:'rgba', label:'RGBA', value: `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${fmt2(a)})`, copy: `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${fmt2(a)})`},
{key:'hsl', label:'HSL', value: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`, copy: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`},
{key:'hsla', label:'HSLA', value: `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${fmt2(a)})`, copy: `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${fmt2(a)})`},
{key:'hsv', label:'HSV / HSB', value: `hsv(${hsv.h}°, ${hsv.s}%, ${hsv.v}%)`, copy: `hsv(${hsv.h}, ${hsv.s}%, ${hsv.v}%)`},
{key:'cmyk', label:'CMYK', value: `cmyk(${cmyk.c}%, ${cmyk.m}%, ${cmyk.y}%, ${cmyk.k}%)`, copy: `cmyk(${cmyk.c}%, ${cmyk.m}%, ${cmyk.y}%, ${cmyk.k}%)`},
{key:'css', label:'CSS custom property', value: `--color: ${hex6};`, copy: `--color: ${hex6};`},
{key:'r', label:'R (0255)', value: String(Math.round(r)), copy: String(Math.round(r)), muted:true},
{key:'g', label:'G (0255)', value: String(Math.round(g)), copy: String(Math.round(g)), muted:true},
{key:'b', label:'B (0255)', value: String(Math.round(b)), copy: String(Math.round(b)), muted:true},
{key:'a', label:'Alpha (01)', value: fmt2(a), copy: String(fmt2(a)), muted:true},
{key:'lum', label:'Relative Luminanz', value: fmt2(lum), copy: String(fmt2(lum)), muted:true},
{key:'cw', label:'Kontrast zu Weiß', value: `${contrastWhite}:1 — WCAG: ${wcagWhite}`, copy: `${contrastWhite}:1`, muted:true},
{key:'cb', label:'Kontrast zu Schwarz', value: `${contrastBlack}:1 — WCAG: ${wcagBlack}`, copy: `${contrastBlack}:1`, muted:true},
].filter(f=>!f.hide);
})() : [];
const previewBg = color ? `rgb(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)})` : 'rgba(255,255,255,0.05)';
const previewText = color ? (luminance(color) > 0.179 ? '#000' : '#fff') : 'rgba(255,255,255,0.3)';
// Gradient bar stops for hue slider background
const hueBar = 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)';
return (
<div>
{/* Eingabe-Zeile */}
<div style={{display:'flex', gap:10, alignItems:'center', marginBottom:16, flexWrap:'wrap'}}>
<div style={{flex:1, minWidth:160}}>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>
Farbe eingeben (HEX, RGB, HSL, Farbname)
</div>
<input style={{...inp, width:'100%',
borderColor: input && !color ? 'rgba(255,107,157,0.5)' : undefined}}
placeholder="#4ecdc4 oder rgb(78,205,196) oder hsl(177,55%,55%)"
value={input} onChange={e=>handleInput(e.target.value)}/>
</div>
{/* Color Picker */}
<div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>
Color Picker
</div>
<div style={{position:'relative', width:42, height:36}}>
<div style={{width:42,height:36,borderRadius:7,background:pickerHex,
border:'2px solid rgba(255,255,255,0.15)',cursor:'pointer',overflow:'hidden'}}>
<input type="color" value={pickerHex}
onChange={e=>handlePicker(e.target.value)}
style={{opacity:0,position:'absolute',inset:0,width:'100%',height:'100%',cursor:'pointer',border:'none',padding:0}}/>
</div>
</div>
</div>
{/* Alpha Slider */}
{!!color && (
<div style={{flex:'0 0 140px'}}>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>
Alpha / Transparenz: {Math.round((color.a??1)*100)}%
</div>
<input type="range" min={0} max={1} step={0.01}
value={color?.a??1}
onChange={e=>applyColor({...color, a:parseFloat(e.target.value)})}
style={{width:'100%', accentColor:'#4ecdc4'}}/>
</div>
)}
{!!input && <button style={{...S.btn('#ff6b9d',false),fontSize:11,padding:'7px 14px'}}
onClick={()=>{setInput('');setColor(null);}}> Leeren</button>}
</div>
{/* Vorschau */}
{color && (
<div style={{borderRadius:10,overflow:'hidden',marginBottom:16,border:'1px solid rgba(255,255,255,0.08)'}}>
{/* Checkerboard + Farbe */}
<div style={{
height:80,
background:`linear-gradient(${previewBg},${previewBg}),
repeating-conic-gradient(rgba(255,255,255,0.1) 0% 25%, transparent 0% 50%) 0 0 / 16px 16px`,
display:'flex',alignItems:'center',justifyContent:'center'}}>
<div style={{
background: color.a < 1
? `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
: previewBg,
width:'100%', height:'100%', display:'flex',alignItems:'center',justifyContent:'center'}}>
<span style={{color:previewText, fontFamily:'monospace', fontSize:13, fontWeight:700,
textShadow: luminance(color)>0.5?'0 1px 2px rgba(0,0,0,0.2)':'0 1px 2px rgba(0,0,0,0.5)'}}>
{rgbToHex({...color,a:1}).toUpperCase()}
</span>
</div>
</div>
{/* Komplementärfarbe Vorschau */}
<div style={{display:'flex',height:32}}>
{[0,30,60,90,120,150,180,210,240,270,300,330].map(dh=>{
const hsl = rgbToHsl(color);
const c2 = hslToRgb({...hsl, h:(hsl.h+dh)%360, a:1});
return <div key={dh} style={{flex:1,background:`rgb(${c2.r},${c2.g},${c2.b})`}} title={`+${dh}°`}/>;
})}
</div>
<div style={{padding:'4px 10px',background:'rgba(0,0,0,0.2)',
color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>
Farbrad-Vorschau (Töne in 30°-Schritten)
</div>
</div>
)}
{/* Format-Ausgaben */}
{!!formats.length && (
<div style={{display:'flex',flexDirection:'column',gap:4}}>
{formats.map(f=>(
<div key={f.key} onClick={()=>copy(f.copy, f.key)}
style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 10px',borderRadius:7,cursor:'pointer',userSelect:'none',
background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.05)',
transition:'background 0.15s'}}
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.07)'}
onMouseLeave={e=>e.currentTarget.style.background='rgba(255,255,255,0.03)'}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,minWidth:120}}>{f.label}</span>
<code style={{color: f.muted?'rgba(255,255,255,0.55)':'#4ecdc4',
fontFamily:"'Courier New',monospace",fontSize:12,fontWeight:700,
flex:1,textAlign:'right',marginLeft:8,wordBreak:'break-all'}}>
{f.value}
</code>
<span style={{color:copied===f.key?'#4ecdc4':'rgba(255,255,255,0.2)',
fontSize:14,marginLeft:10,flexShrink:0,transition:'color 0.2s'}}>
{copied===f.key?'✓':'⎘'}
</span>
</div>
))}
</div>
)}
{!color && !input && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,
padding:'20px 0',textAlign:'center'}}>
Farbe eingeben oder im Color Picker auswählen alle Formate werden berechnet
</div>
)}
{!!input && !color && (
<div style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:11,padding:'8px 0'}}>
Format nicht erkannt · Beispiele: <code>#ff6b9d</code> · <code>rgb(255, 107, 157)</code> · <code>hsl(340, 100%, 71%)</code>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────── 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(targetCanvas, data, opts) {
const { size, fgColor, bgColor, margin, caption, captionPos, captionColor, captionSize } = opts;
const capH = caption && captionPos !== 'none' ? (Number(captionSize) + 16) : 0;
const totalH = Number(size) + capH;
// Step 1: generate QR to a temp canvas via qrcode library (browser-compatible)
const tmp = document.createElement('canvas');
return QRCode.toCanvas(tmp, data, {
width: Number(size),
margin: Number(margin) || 1,
color: { dark: fgColor, light: bgColor },
errorCorrectionLevel: 'M',
}).then(() => {
// Step 2: compose onto target canvas (with optional caption)
targetCanvas.width = Number(size);
targetCanvas.height = totalH;
const ctx = targetCanvas.getContext('2d');
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, Number(size), totalH);
// QR image
const qrTop = captionPos === 'top' ? capH : 0;
ctx.drawImage(tmp, 0, qrTop, Number(size), Number(size));
// 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 : Number(size) + capH / 2;
ctx.fillText(caption, Number(size) / 2, cy, Number(size) - 16);
}
}).catch(() => {
// Show error on canvas
targetCanvas.width = Number(size);
targetCanvas.height = Number(size);
const ctx = targetCanvas.getContext('2d');
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, Number(size), Number(size));
ctx.fillStyle = '#ff6b9d';
ctx.font = '13px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Ungültige URL', Number(size)/2, Number(size)/2);
});
}
function QrGenerator({ toast, mobile }) {
const canvasRef = useRef(null);
const [tab, setTab] = useState('create');
const [saved, setSaved] = useState([]);
const [editing, setEditing] = useState(null);
const [generated, setGenerated] = useState(false); // whether QR is shown
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})); setGenerated(false); };
const loadSaved = () => api('/tools/qrcodes').then(setSaved).catch(()=>{});
useEffect(()=>{ loadSaved(); },[]);
const generate = async () => {
if (!form.url.trim()) { toast('URL erforderlich','error'); return; }
if (!canvasRef.current) return;
await 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,
});
setGenerated(true);
};
const download = () => {
if (!canvasRef.current || !generated) 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={generate}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',fontWeight:700}}>
Generieren
</button>
{editing && <button onClick={()=>{setEditing(null);setForm(DEFAULTS);setGenerated(false);}}
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>
<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)',
display: generated ? 'block' : 'none'}}/>
{!generated && (
<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',flexDirection:'column',
alignItems:'center',justifyContent:'center',gap:10}}>
<span style={{fontSize:36,opacity:0.2}}></span>
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:10,textAlign:'center'}}>
Einstellungen wählen{String.fromCharCode(10)}dann Generieren"
</span>
</div>
)}
{generated && (
<div style={{display:'flex',gap:8}}>
<button onClick={download} style={{...S.btn('#ffe66d',true),fontSize:11,padding:'6px 14px'}}>
↓ PNG laden
</button>
<button onClick={save} disabled={busy}
style={{...S.btn('#4ecdc4'),fontSize:11,padding:'6px 14px',opacity:busy?0.5:1}}>
{busy?'…':editing?'✓ Aktualisieren':'💾 Speichern'}
</button>
</div>
)}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,textAlign:'center',lineHeight:1.6}}>
Lokal generiert · 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,
tabs:[{id:'explain',label:'Erklären'},{id:'build',label:'Erstellen'}],
components:{explain:CronExplainer,build:CronBuilder}},
{id:'elektro', label:'⚡ Elektro', desc:'Ohm, Leistung, Kosten, Widerstände', ready:true, component:ElektroRechner},
{id:'json', label:'{ } JSON', desc:'JSON ansehen & bauen', ready:true, component:JsonTool},
{id:'regex', label:'🔍 Regex', desc:'Regex bauen & erklären', ready:true, component:RegexBuilder},
{id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:true, component:TextDiff},
{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 }) {
const [activeTool, setActiveTool] = useState('cron');
const [activeTab, setActiveTab] = useState('explain');
const active = TOOL_DEFS.find(t=>t.id===activeTool);
const ActiveComp = active?.tabs ? active.components[activeTab] : active?.component;
// Erstes Zeichen als Icon, Rest als Text (handles Emoji korrekt via spread)
const getIcon = lbl => [...lbl][0] ?? '🔧';
const getText = lbl => { const c=[...lbl]; return c.slice(1).join('').trim() || lbl; };
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:16,flexWrap:'wrap'}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Dev-Tools</h1>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10}}>Konverter · Formatter · Helfer</span>
</div>
{/* Tool-Auswahl: Mobile = horizontal scroll mit Fade-Hint, Desktop = 2-Spalten-Grid */}
{mobile ? (
<div style={{position:'relative', marginBottom:16}}>
<div style={{display:'flex',gap:6,overflowX:'auto',
padding:'3px 3px 6px 3px', /* Platz für border, kein clip */
scrollbarWidth:'none',WebkitOverflowScrolling:'touch'}}>
{TOOL_DEFS.map(t=>(
<button key={t.id}
onClick={()=>{ if(t.ready){setActiveTool(t.id);setActiveTab(t.tabs?.[0]?.id||'');} else toast('Kommt bald 🚧'); }}
style={{
display:'flex', alignItems:'center', gap:6, flexShrink:0,
padding:'7px 12px', borderRadius:20, fontFamily:'monospace', fontSize:11,
cursor:t.ready?'pointer':'default', whiteSpace:'nowrap',
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.05)',
border: activeTool===t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.1)',
color: activeTool===t.id ? '#4ecdc4' : t.ready ? 'rgba(255,255,255,0.75)' : 'rgba(255,255,255,0.3)',
fontWeight: activeTool===t.id ? 700 : 400,
}}>
<span style={{fontSize:15}}>{getIcon(t.label)}</span>
<span>{getText(t.label)}</span>
</button>
))}
{/* Abstandshalter rechts damit letzter Chip nicht unter Fade liegt */}
<div style={{flexShrink:0, width:32}}/>
</div>
{/* Fade-Overlay rechts → signalisiert weiteren Inhalt ohne Chips abzuschneiden */}
<div style={{
position:'absolute', right:0, top:0, bottom:6, width:48, pointerEvents:'none',
background:'linear-gradient(to right, transparent, #0d0d0f 80%)',
}}/>
{/* Scroll-Pfeil */}
<div style={{
position:'absolute', right:6, top:'50%', transform:'translateY(-60%)',
color:'rgba(255,255,255,0.35)', fontSize:14, pointerEvents:'none',
}}></div>
</div>
) : (
<div style={{display:'grid',gridTemplateColumns:'repeat(2,1fr)',gap:5,marginBottom:16}}>
{TOOL_DEFS.map(t=>(
<button key={t.id} title={t.desc}
onClick={()=>{ if(t.ready){setActiveTool(t.id);setActiveTab(t.tabs?.[0]?.id||'');} else toast('Kommt bald 🚧'); }}
style={{
display:'flex', alignItems:'center', gap:9,
padding:'8px 11px', borderRadius:9, fontFamily:'monospace',
cursor:t.ready?'pointer':'default', textAlign:'left', border:'none',
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : t.ready ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.02)',
outline: activeTool===t.id ? '1px solid #4ecdc4' : 'none',
}}>
<span style={{fontSize:17,flexShrink:0,lineHeight:1,minWidth:22,textAlign:'center'}}>{getIcon(t.label)}</span>
<div style={{minWidth:0,flex:1}}>
<div style={{
color: activeTool===t.id ? '#4ecdc4' : t.ready ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.3)',
fontSize:12, fontWeight: activeTool===t.id?700:500,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}>
{getText(t.label)}
</div>
<div style={{color:'rgba(255,255,255,0.28)',fontSize:9,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
{t.desc}
</div>
</div>
</button>
))}
</div>
)}
<div style={{...S.card}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:14,flexWrap:'wrap'}}>
<div style={{...S.head,marginBottom:0}}>{active?.label}</div>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10}}>{active?.desc}</span>
</div>
{active?.tabs&&(
<div style={{display:'flex',gap:6,marginBottom:16}}>
{active.tabs.map(tab=>(
<button key={tab.id} onClick={()=>setActiveTab(tab.id)} style={{
padding:'6px 18px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:activeTab===tab.id?'#4ecdc4':'rgba(255,255,255,0.05)',
color: activeTab===tab.id?'#0d0d0f':'rgba(255,255,255,0.5)',
border: activeTab===tab.id?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight: activeTab===tab.id?700:400}}>
{tab.label}
</button>
))}
</div>
)}
{ActiveComp&&<ActiveComp toast={toast} mobile={mobile}/>}
</div>
</div>
);
}