3285 lines
162 KiB
JavaScript
3285 lines
162 KiB
JavaScript
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','Mo–Fr 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:'Mo–Fr 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">Mo–Fr (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 4: In X Stunden/Minuten ──
|
||
const [inH, setInH] = useState('');
|
||
const [inM, setInM] = useState('');
|
||
const inResult = (() => {
|
||
const h = parseInt(inH) || 0;
|
||
const m = parseInt(inM) || 0;
|
||
if (h === 0 && m === 0) return null;
|
||
const future = new Date(Date.now() + (h * 60 + m) * 60 * 1000);
|
||
return {
|
||
de: future.toLocaleDateString('de-DE', { timeZone:'Europe/Berlin', weekday:'long', day:'2-digit', month:'2-digit', year:'numeric' })
|
||
+ ', ' + future.toLocaleTimeString('de-DE', { timeZone:'Europe/Berlin', hour:'2-digit', minute:'2-digit' }) + ' Uhr',
|
||
unix: Math.floor(future.getTime() / 1000),
|
||
total: h * 60 + m,
|
||
};
|
||
})();
|
||
|
||
// ── 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'},{id:'in',label:'⏩ In X Zeit'}];
|
||
|
||
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 ·
|
||
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>
|
||
)}
|
||
|
||
{tab==='in' && (
|
||
<div>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,marginBottom:14}}>
|
||
Ab jetzt in wie vielen Stunden/Minuten?
|
||
</div>
|
||
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:16,flexWrap:'wrap'}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:6}}>
|
||
<input
|
||
type="number" min="0" max="999"
|
||
value={inH} onChange={e=>setInH(e.target.value)}
|
||
placeholder="0"
|
||
style={{...inp, width:70, textAlign:'center'}}
|
||
/>
|
||
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Stunden</span>
|
||
</div>
|
||
<div style={{display:'flex',alignItems:'center',gap:6}}>
|
||
<input
|
||
type="number" min="0" max="59"
|
||
value={inM} onChange={e=>setInM(e.target.value)}
|
||
placeholder="0"
|
||
style={{...inp, width:70, textAlign:'center'}}
|
||
/>
|
||
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Minuten</span>
|
||
</div>
|
||
</div>
|
||
{inResult && (
|
||
<div style={{background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.08)',
|
||
borderRadius:10,padding:'14px 16px'}}>
|
||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:6}}>
|
||
IN {inResult.total} MINUTEN ({Math.floor(inResult.total/60)}h {inResult.total%60}m) IST ES:
|
||
</div>
|
||
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:18,fontWeight:700,marginBottom:8}}>
|
||
{inResult.de}
|
||
</div>
|
||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>
|
||
Unix: {inResult.unix}
|
||
</span>
|
||
<button onClick={()=>navigator.clipboard?.writeText(inResult.de)}
|
||
style={{background:'rgba(78,205,196,0.1)',border:'1px solid rgba(78,205,196,0.3)',
|
||
borderRadius:6,padding:'3px 10px',color:'#4ecdc4',fontFamily:'monospace',fontSize:10,cursor:'pointer'}}>
|
||
📋 Kopieren
|
||
</button>
|
||
</div>
|
||
</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 · 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 ·
|
||
Broadcast = letzte Adresse · 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 (von–bis)', 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 (0–9)` };
|
||
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 (0–9)'],
|
||
[/\\d\*/g, 'Beliebig viele Ziffern (0–9)'],
|
||
[/\\d\?/g, 'Optionale Ziffer (0–9)'],
|
||
[/\\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 (0–9)'],
|
||
[/\\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 (0–9)','\\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,'&').replace(/</g,'<').replace(/>/g,'>')}</${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 (0–255)', value: String(Math.round(r)), copy: String(Math.round(r)), muted:true},
|
||
{key:'g', label:'G (0–255)', value: String(Math.round(g)), copy: String(Math.round(g)), muted:true},
|
||
{key:'b', label:'B (0–255)', value: String(Math.round(b)), copy: String(Math.round(b)), muted:true},
|
||
{key:'a', label:'Alpha (0–1)', 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, nav }) {
|
||
const [activeTool, setActiveTool] = useState('cron');
|
||
const [activeTab, setActiveTab] = useState('explain');
|
||
|
||
// Von außen navigieren (z.B. über Suche)
|
||
useEffect(() => {
|
||
if (nav?.tool) {
|
||
setActiveTool(nav.tool);
|
||
setActiveTab('');
|
||
}
|
||
}, [nav?.ts]);
|
||
const chipScrollRef = useRef(null);
|
||
const [chipScroll, setChipScroll] = useState({ left: false, right: true });
|
||
|
||
const onChipScroll = () => {
|
||
const el = chipScrollRef.current;
|
||
if (!el) return;
|
||
setChipScroll({
|
||
left: el.scrollLeft > 4,
|
||
right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4,
|
||
});
|
||
};
|
||
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 Pfeil-Hints, Desktop = 2-Spalten-Grid */}
|
||
{mobile ? (
|
||
<div style={{marginBottom:16}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:4}}>
|
||
{chipScroll.left && (
|
||
<button onClick={()=>{ chipScrollRef.current?.scrollBy({left:-160,behavior:'smooth'}); }}
|
||
style={{flexShrink:0,background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
|
||
borderRadius:20,color:'rgba(255,255,255,0.6)',cursor:'pointer',
|
||
padding:'6px 8px',fontSize:14,lineHeight:1}}>‹</button>
|
||
)}
|
||
<div ref={chipScrollRef} onScroll={onChipScroll}
|
||
style={{display:'flex',gap:6,overflowX:'auto',flex:1,
|
||
padding:'3px 2px 6px',
|
||
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>
|
||
))}
|
||
</div>
|
||
{chipScroll.right && (
|
||
<button onClick={()=>{ chipScrollRef.current?.scrollBy({left:160,behavior:'smooth'}); }}
|
||
style={{flexShrink:0,background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
|
||
borderRadius:20,color:'rgba(255,255,255,0.6)',cursor:'pointer',
|
||
padding:'6px 8px',fontSize:14,lineHeight:1}}>›</button>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|