DevTools: Regex-Builder (visuell + Erklärungsmodus) hinzugefügt

This commit is contained in:
2026-06-01 12:43:10 +02:00
parent ec3bf2d176
commit 0b31804d21

View File

@@ -1409,13 +1409,469 @@ function NetzwerkRechner({ mobile }) {
);
}
// ──────────────────────────────────────────────────────────── 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', 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: `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 (lo === hi) return String(lo);
if (lo < 0 || hi < 0) return `(-?\\d+)`; // fallback for negative
return `(${lo}|${lo+1 <= hi ? `[${lo}-${hi}]` : lo})`.replace(/\((\d+)\)/, '$1');
// For proper range, just do a simple approach:
}
// 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;
}
function RegexBuilder({ toast, mobile }) {
const [mode, setMode] = useState('build'); // 'build' | 'explain'
// ── Build mode ──
const [rules, setRules] = useState([]);
const [testVal, setTestVal] = useState('');
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 {
testResult = new RegExp(regex).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 explained = explainInput ? explainRegex(explainInput) : [];
let explainTestResult = null;
if (explainInput && explainTestVal !== '') {
try { explainTestResult = new RegExp(explainInput).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};
const FIELD_LABELS = {text:'Text', count:'Anzahl', min:'Von', max:'Bis', pattern:'Muster'};
function RuleCard({rule, idx}) {
const rt = RULE_TYPES.find(r=>r.id===rule.type);
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={()=>moveRule(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={()=>moveRule(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}>Regeltyp</div>
<select style={{...inp, width:'auto', cursor:'pointer'}} value={rule.type}
onChange={e=>updateRule(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}>{FIELD_LABELS[f]||f}</div>
<input style={{...inp, width:'100%'}} type={f==='pattern'?'text':'text'}
placeholder={rt.hint||''}
value={rule.fields[f]||''}
onChange={e=>updateRule(rule.id,{fields:{...rule.fields,[f]:e.target.value}})}/>
</div>
))}
{/* Preview of this rule's regex */}
<div style={{flex:'1 1 100px', minWidth:100}}>
<div style={lbl}>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}}>
{ruleToRegex(rule).pat || <span style={{color:'rgba(255,255,255,0.2)'}}></span>}
</code>
</div>
{/* Delete */}
<button onClick={()=>deleteRule(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>
{/* Description of this rule */}
<div style={{marginTop:6,marginLeft:60,color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
{ruleToRegex(rule).desc}
</div>
</div>
);
}
return (
<div>
{/* Mode tabs */}
<div style={{display:'flex',gap:6,marginBottom:20}}>
{[['build','🔨 Regex bauen'],['explain','🔍 Regex erklären']].map(([id,lbl])=>(
<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}}>{lbl}</button>
))}
</div>
{mode==='build' && (
<div>
{/* Regel-Karten */}
{rules.map((rule,idx)=>(
<RuleCard key={rule.id} rule={rule} idx={idx}/>
))}
<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={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>TESTEN</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={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10,marginBottom:8}}>TESTEN</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>
);
}
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 formatieren', ready:false},
{id:'regex', label:'🔍 Regex', desc:'Regex testen', ready:false},
{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},