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

358 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, 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>
);
}