358 lines
17 KiB
JavaScript
358 lines
17 KiB
JavaScript
import { useState, useCallback } from 'react';
|
||
import { S } from '../lib.js';
|
||
import jsyaml from 'js-yaml';
|
||
|
||
// ── YAML Tree Node (collapsible, mit Einrück-Linien) ─────────────────────────
|
||
function YamlNode({ data, depth = 0, keyName = null, collapsed, onToggle, path }) {
|
||
const indent = depth * 18; // px pro Level
|
||
const isObj = data !== null && typeof data === 'object' && !Array.isArray(data);
|
||
const isArr = Array.isArray(data);
|
||
const isLeaf = !isObj && !isArr;
|
||
|
||
const nodeKey = path;
|
||
const isCollapsed = collapsed.has(nodeKey);
|
||
const hasChildren = isObj || isArr;
|
||
|
||
const keyColor = '#7ecaff';
|
||
const strColor = '#ce9178';
|
||
const numColor = '#b5cea8';
|
||
const boolColor = '#569cd6';
|
||
const nullColor = '#808080';
|
||
const indentColor= 'rgba(255,255,255,0.08)';
|
||
|
||
const valueColor = (v) => {
|
||
if (v === null || v === undefined) return nullColor;
|
||
if (typeof v === 'boolean') return boolColor;
|
||
if (typeof v === 'number') return numColor;
|
||
return strColor;
|
||
};
|
||
|
||
const renderValue = (v) => {
|
||
if (v === null) return <span style={{color:nullColor}}>null</span>;
|
||
if (v === undefined) return <span style={{color:nullColor}}>~</span>;
|
||
if (typeof v === 'boolean') return <span style={{color:boolColor}}>{String(v)}</span>;
|
||
if (typeof v === 'number') return <span style={{color:numColor}}>{v}</span>;
|
||
const s = String(v);
|
||
// Quote strings that need it
|
||
const needsQuotes = /[:#\[\]{}&*!|>'"%@`]/.test(s) || s.trim()!==s || s==='' || /^(true|false|null|yes|no|on|off)$/i.test(s);
|
||
return <span style={{color:strColor}}>{needsQuotes ? `"${s.replace(/"/g,'\\"')}"` : s}</span>;
|
||
};
|
||
|
||
// Leaf: key: value
|
||
if (isLeaf) {
|
||
return (
|
||
<div style={{display:'flex',alignItems:'baseline',minHeight:20,position:'relative'}}>
|
||
{/* Vertical indent lines */}
|
||
{Array.from({length:depth},(_,i)=>(
|
||
<div key={i} style={{position:'absolute',left:i*18+6,top:0,bottom:0,
|
||
borderLeft:`1px solid ${indentColor}`,pointerEvents:'none'}}/>
|
||
))}
|
||
<div style={{paddingLeft:indent,fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7,whiteSpace:'pre-wrap',wordBreak:'break-all'}}>
|
||
{keyName!==null&&<span style={{color:keyColor}}>{keyName}<span style={{color:'rgba(255,255,255,0.4)'}}>: </span></span>}
|
||
{renderValue(data)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const count = isObj ? Object.keys(data).length : data.length;
|
||
const summary = isObj ? `{${count}}` : `[${count}]`;
|
||
|
||
return (
|
||
<div style={{position:'relative'}}>
|
||
{/* Vertical indent lines for this level */}
|
||
{Array.from({length:depth},(_,i)=>(
|
||
<div key={i} style={{position:'absolute',left:i*18+6,top:0,bottom:0,
|
||
borderLeft:`1px solid ${indentColor}`,pointerEvents:'none'}}/>
|
||
))}
|
||
|
||
{/* Header row */}
|
||
<div style={{display:'flex',alignItems:'center',minHeight:20,cursor:hasChildren?'pointer':'default',
|
||
paddingLeft:indent,userSelect:'none'}}
|
||
onClick={()=>hasChildren&&onToggle(nodeKey)}>
|
||
{hasChildren&&(
|
||
<span style={{color:'rgba(255,255,255,0.4)',fontSize:9,marginRight:5,flexShrink:0,
|
||
transform:isCollapsed?'rotate(-90deg)':'rotate(0)',display:'inline-block',transition:'transform 0.15s'}}>▼</span>
|
||
)}
|
||
<span style={{fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7}}>
|
||
{keyName!==null&&<span style={{color:keyColor}}>{keyName}<span style={{color:'rgba(255,255,255,0.4)'}}>: </span></span>}
|
||
{isCollapsed&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10}}>{summary}</span>}
|
||
{!isCollapsed&&isArr&&<span style={{color:'rgba(255,255,255,0.25)',fontSize:10}}># {count} Einträge</span>}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Children */}
|
||
{!isCollapsed && (
|
||
<div>
|
||
{isObj && Object.entries(data).map(([k,v],i)=>(
|
||
<YamlNode key={k} data={v} depth={depth+1} keyName={k}
|
||
collapsed={collapsed} onToggle={onToggle} path={`${path}.${k}`}/>
|
||
))}
|
||
{isArr && data.map((v,i)=>(
|
||
<div key={i} style={{position:'relative'}}>
|
||
<div style={{position:'absolute',left:(depth)*18+6,top:10,width:8,
|
||
borderTop:`1px solid ${indentColor}`}}/>
|
||
<div style={{paddingLeft:(depth+1)*18,minHeight:20,display:'flex',alignItems:'baseline'}}>
|
||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:"'Courier New',monospace",fontSize:12,
|
||
position:'absolute',left:(depth)*18+8}}>-</span>
|
||
{typeof v === 'object' && v !== null
|
||
? <YamlNode data={v} depth={depth+1} keyName={null}
|
||
collapsed={collapsed} onToggle={onToggle} path={`${path}[${i}]`}/>
|
||
: <div style={{fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7}}>
|
||
{v===null?<span style={{color:nullColor}}>null</span>:typeof v==='boolean'?<span style={{color:boolColor}}>{String(v)}</span>:typeof v==='number'?<span style={{color:numColor}}>{v}</span>:<span style={{color:strColor}}>{String(v)}</span>}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function collectPaths(data, path='root', paths=[]) {
|
||
if (data !== null && typeof data === 'object') {
|
||
paths.push(path);
|
||
if (Array.isArray(data)) data.forEach((_,i)=>collectPaths(data[i],`${path}[${i}]`,paths));
|
||
else Object.entries(data).forEach(([k,v])=>collectPaths(v,`${path}.${k}`,paths));
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
// ── YAML Auto-Fix: normalisiert Einrückung bevor js-yaml parst ───────────────
|
||
function fixYamlIndentation(text) {
|
||
const lines = text.split('\n');
|
||
|
||
// 1. Tabs → Spaces (4 oder 2)
|
||
const detabbed = lines.map(l => l.replace(/\t/g, ' '));
|
||
|
||
// 2. Finde die kleinste vorkommende Einrückung > 0 (das ist die Einheit)
|
||
const indents = detabbed
|
||
.filter(l => l.trim() && !l.trim().startsWith('#'))
|
||
.map(l => l.search(/\S/))
|
||
.filter(n => n > 0);
|
||
const unit = indents.length ? Math.min(...indents) : 2;
|
||
|
||
// 3. Remap: jede Einrückung → Niveau * 2 Spaces
|
||
const remapped = detabbed.map(line => {
|
||
if (!line.trim()) return '';
|
||
if (line.trim().startsWith('#')) return line.trimEnd();
|
||
const spaces = line.search(/\S/);
|
||
if (spaces <= 0) return line.trimEnd();
|
||
const level = Math.round(spaces / unit);
|
||
return ' '.repeat(level) + line.trimStart().trimEnd();
|
||
});
|
||
|
||
return remapped.join('\n');
|
||
}
|
||
|
||
// ── YAML Tool ─────────────────────────────────────────────────────────────────
|
||
function YamlTool({ mobile }) {
|
||
const [input, setInput] = useState('');
|
||
const [parsed, setParsed] = useState(null);
|
||
const [formatted,setFormatted]= useState('');
|
||
const [error, setError] = useState('');
|
||
const [warning, setWarning] = useState('');
|
||
const [copied, setCopied] = useState(false);
|
||
const [view, setView] = useState('tree');
|
||
const [collapsed,setCollapsed]= useState(new Set());
|
||
|
||
const process = useCallback((text) => {
|
||
if (!text.trim()) { setParsed(null); setFormatted(''); setError(''); setWarning(''); return; }
|
||
|
||
let obj, parseError = null;
|
||
|
||
// Versuch 1: direkt parsen
|
||
try { obj = jsyaml.load(text); }
|
||
catch(e) { parseError = e; }
|
||
|
||
// Versuch 2: wenn Fehler → Auto-Fix, dann nochmal
|
||
if (parseError) {
|
||
try {
|
||
const fixed = fixYamlIndentation(text);
|
||
obj = jsyaml.load(fixed);
|
||
setWarning(`⚠ Einrückung automatisch korrigiert: ${parseError.message.split('\n')[0]}`);
|
||
} catch(e2) {
|
||
// Fix hat auch nicht geholfen – originalen Fehler zeigen
|
||
setError(parseError.message);
|
||
setWarning('');
|
||
setParsed(null); setFormatted('');
|
||
return;
|
||
}
|
||
} else {
|
||
setWarning('');
|
||
}
|
||
|
||
try {
|
||
setParsed(obj);
|
||
setFormatted(jsyaml.dump(obj, { indent:2, lineWidth:-1, noRefs:true }));
|
||
setError('');
|
||
setCollapsed(new Set());
|
||
} catch(e) {
|
||
setError(e.message);
|
||
setParsed(null); setFormatted('');
|
||
}
|
||
},[]);
|
||
|
||
const onInput = v => { setInput(v); process(v); };
|
||
|
||
const copy = async () => {
|
||
try { await navigator.clipboard.writeText(formatted); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {}
|
||
};
|
||
|
||
const toggleCollapse = path => {
|
||
setCollapsed(prev => {
|
||
const n = new Set(prev);
|
||
n.has(path) ? n.delete(path) : n.add(path);
|
||
return n;
|
||
});
|
||
};
|
||
|
||
const collapseAll = () => { if(parsed) setCollapsed(new Set(collectPaths(parsed))); };
|
||
const expandAll = () => setCollapsed(new Set());
|
||
|
||
const colCols = mobile ? '1fr' : '1fr 1fr';
|
||
|
||
return (
|
||
<div>
|
||
<div style={{display:'grid',gridTemplateColumns:colCols,gap:12,marginBottom:10}}>
|
||
{/* Input */}
|
||
<div>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
|
||
<label style={{...S.head,marginBottom:0,fontSize:10}}>EINGABE</label>
|
||
{input&&<button onClick={()=>{setInput('');setParsed(null);setFormatted('');setError('');}}
|
||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.3)',cursor:'pointer',fontFamily:'monospace',fontSize:10}}>✕</button>}
|
||
</div>
|
||
<textarea value={input} onChange={e=>onInput(e.target.value)}
|
||
placeholder={'name: Beispiel\nversion: 1.0\nkonfiguration:\n host: localhost\n port: 8080\nliste:\n - item1\n - item2'}
|
||
rows={mobile?10:18}
|
||
style={{...S.inp,resize:'vertical',fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7,
|
||
width:'100%',boxSizing:'border-box'}}/>
|
||
</div>
|
||
|
||
{/* Output */}
|
||
<div>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6,flexWrap:'wrap',gap:6}}>
|
||
<label style={{...S.head,marginBottom:0,fontSize:10}}>ERGEBNIS</label>
|
||
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
|
||
{/* View toggle */}
|
||
{['tree','raw'].map(v=>(
|
||
<button key={v} onClick={()=>setView(v)} style={{
|
||
padding:'2px 8px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',
|
||
background:view===v?'rgba(78,205,196,0.15)':'transparent',
|
||
border:`1px solid ${view===v?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||
color:view===v?'#4ecdc4':'rgba(255,255,255,0.4)'}}>
|
||
{v==='tree'?'🌲 Baum':'📄 Raw'}
|
||
</button>
|
||
))}
|
||
{parsed&&view==='tree'&&<>
|
||
<button onClick={collapseAll} style={{padding:'2px 8px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',background:'transparent',border:'1px solid rgba(255,255,255,0.1)',color:'rgba(255,255,255,0.4)'}}>− Alle</button>
|
||
<button onClick={expandAll} style={{padding:'2px 8px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',background:'transparent',border:'1px solid rgba(255,255,255,0.1)',color:'rgba(255,255,255,0.4)'}}>+ Alle</button>
|
||
</>}
|
||
{formatted&&<button onClick={copy} style={{
|
||
padding:'2px 9px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',
|
||
background:copied?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.05)',
|
||
border:`1px solid ${copied?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||
color:copied?'#4ecdc4':'rgba(255,255,255,0.5)'}}>
|
||
{copied?'✓':'⎘'} Kopieren
|
||
</button>}
|
||
</div>
|
||
</div>
|
||
|
||
{error ? (
|
||
<div style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.2)',
|
||
borderRadius:8,padding:14,color:'#ff6b9d',fontFamily:'monospace',fontSize:11,lineHeight:1.7}}>
|
||
⚠ {error}
|
||
</div>
|
||
) : (<>
|
||
{warning && (
|
||
<div style={{background:'rgba(255,230,109,0.07)',border:'1px solid rgba(255,230,109,0.2)',
|
||
borderRadius:7,padding:'7px 12px',color:'#ffe66d',fontFamily:'monospace',
|
||
fontSize:10,lineHeight:1.6,marginBottom:8}}>
|
||
{warning}
|
||
</div>
|
||
)}
|
||
{view==='raw' ? (
|
||
<pre style={{margin:0,padding:14,minHeight:200,background:'rgba(0,0,0,0.35)',
|
||
border:'1px solid rgba(255,255,255,0.07)',borderRadius:8,
|
||
color:formatted?'#d4e6b5':'rgba(255,255,255,0.15)',
|
||
fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7,
|
||
overflowX:'auto',whiteSpace:'pre',maxHeight:440,overflowY:'auto'}}>
|
||
{formatted||'# Ergebnis erscheint hier'}
|
||
</pre>
|
||
) : parsed!==null ? (
|
||
<div style={{background:'rgba(0,0,0,0.35)',border:'1px solid rgba(255,255,255,0.07)',
|
||
borderRadius:8,padding:'10px 10px 10px 12px',minHeight:200,maxHeight:440,overflowY:'auto',
|
||
overflowX:'auto',position:'relative'}}>
|
||
<YamlNode data={parsed} depth={0} keyName={null}
|
||
collapsed={collapsed} onToggle={toggleCollapse} path="root"/>
|
||
</div>
|
||
) : (
|
||
<div style={{background:'rgba(0,0,0,0.2)',border:'1px solid rgba(255,255,255,0.06)',
|
||
borderRadius:8,padding:30,textAlign:'center',color:'rgba(255,255,255,0.15)',
|
||
fontFamily:'monospace',fontSize:11,minHeight:100,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||
YAML eingeben → wird live formatiert
|
||
</div>
|
||
)}
|
||
</>)}
|
||
</div>
|
||
</div>
|
||
<div style={{color:'rgba(255,255,255,0.15)',fontFamily:'monospace',fontSize:9}}>
|
||
Powered by js-yaml · 2-Space-Einrückung · YAML 1.2 kompatibel
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Tool-Definitionen ─────────────────────────────────────────────────────────
|
||
const TOOL_DEFS = [
|
||
{ id:'yaml', label:'📄 YAML Formatter', desc:'YAML korrekt einrücken und formatieren', ready:true, component:YamlTool },
|
||
{ id:'json', label:'{ } JSON Formatter', desc:'JSON formatieren und validieren', ready:false },
|
||
{ id:'base64', label:'🔐 Base64', desc:'Text ↔ Base64 kodieren/dekodieren', ready:false },
|
||
{ id:'regex', label:'🔍 Regex Tester', desc:'Reguläre Ausdrücke live testen', ready:false },
|
||
{ id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:false },
|
||
{ id:'jwt', label:'🔑 JWT Decoder', desc:'JSON Web Tokens dekodieren', ready:false },
|
||
{ id:'timestamp', label:'🕐 Timestamp', desc:'Unix Timestamps umrechnen', ready:false },
|
||
{ id:'color', label:'🎨 Farben', desc:'HEX ↔ RGB ↔ HSL umrechnen', ready:false },
|
||
];
|
||
|
||
export default function DevTools({ toast, mobile }) {
|
||
const [activeTool, setActiveTool] = useState('yaml');
|
||
const active = TOOL_DEFS.find(t=>t.id===activeTool);
|
||
|
||
return (
|
||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:1100}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:20,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.25)',fontFamily:'monospace',fontSize:10}}>Konverter · Formatter · Helfer</span>
|
||
</div>
|
||
|
||
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:20}}>
|
||
{TOOL_DEFS.map(t=>(
|
||
<button key={t.id}
|
||
onClick={()=>t.ready ? setActiveTool(t.id) : toast('Kommt bald 🚧','error')}
|
||
title={t.desc}
|
||
style={{
|
||
padding:'8px 14px',borderRadius:10,fontFamily:'monospace',fontSize:11,cursor:t.ready?'pointer':'default',
|
||
background: activeTool===t.id?'#4ecdc4':t.ready?'rgba(255,255,255,0.06)':'rgba(255,255,255,0.02)',
|
||
color: activeTool===t.id?'#0d0d0f':t.ready?'rgba(255,255,255,0.65)':'rgba(255,255,255,0.2)',
|
||
border: activeTool===t.id?'none':t.ready?'1px solid rgba(255,255,255,0.1)':'1px solid rgba(255,255,255,0.05)',
|
||
fontWeight: activeTool===t.id?700:400,
|
||
}}>
|
||
{t.label}{!t.ready&&<span style={{marginLeft:4,fontSize:8,opacity:0.5}}>bald</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{...S.card}}>
|
||
<div style={{display:'flex',alignItems:'baseline',gap:10,marginBottom:14}}>
|
||
<div style={{...S.head,marginBottom:0}}>{active?.label}</div>
|
||
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10}}>{active?.desc}</span>
|
||
</div>
|
||
{active?.component && <active.component toast={toast} mobile={mobile}/>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|