diff --git a/frontend/package.json b/frontend/package.json index 1fbca73..9c7c6cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build": "vite build" }, "dependencies": { + "js-yaml": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/frontend/src/tools/devtools.jsx b/frontend/src/tools/devtools.jsx index 119fe40..47e6a67 100644 --- a/frontend/src/tools/devtools.jsx +++ b/frontend/src/tools/devtools.jsx @@ -1,203 +1,245 @@ -import { useState, useRef } from 'react'; +import { useState, useCallback } from 'react'; import { S } from '../lib.js'; +import jsyaml from 'js-yaml'; -// ── YAML Formatter ──────────────────────────────────────────────────────────── -function parseAndFormatYaml(input) { - // YAML-konformes Einrücken mit 2 Spaces - // Wir parsen manuell: kein externes package, aber korrekte Einrückung - const lines = input.split('\n'); - const out = []; - let inMultiline = false; - let multilineIndent = 0; +// ── 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; - for (let i = 0; i < lines.length; i++) { - const raw = lines[i]; - const trimmed = raw.trim(); + const nodeKey = path; + const isCollapsed = collapsed.has(nodeKey); + const hasChildren = isObj || isArr; - // Leerzeilen beibehalten - if (!trimmed) { out.push(''); continue; } + const keyColor = '#7ecaff'; + const strColor = '#ce9178'; + const numColor = '#b5cea8'; + const boolColor = '#569cd6'; + const nullColor = '#808080'; + const indentColor= 'rgba(255,255,255,0.08)'; - // Kommentare unverändert - if (trimmed.startsWith('#')) { - const depth = detectDepth(raw); - out.push(' '.repeat(depth) + trimmed); - continue; - } - - // Multiline-Block (| oder >) – Zeilen danach unverändert einrücken - if (inMultiline) { - // Endet wenn Einrückung nicht mehr tiefer ist - const currentIndent = raw.search(/\S/); - if (currentIndent <= multilineIndent && trimmed) { - inMultiline = false; - } else { - out.push(raw); // Multiline-Inhalt unverändert - continue; - } - } - - const depth = detectDepth(raw); - const indent = ' '.repeat(depth); - - // Key: Value - const colonIdx = findKeyColon(trimmed); - if (colonIdx > 0) { - const key = trimmed.slice(0, colonIdx).trim(); - const value = trimmed.slice(colonIdx + 1).trim(); - - if (value === '|' || value === '>' || value === '|-' || value === '>-') { - inMultiline = true; - multilineIndent = raw.search(/\S/); - out.push(`${indent}${key}: ${value}`); - } else if (value === '') { - out.push(`${indent}${key}:`); - } else { - out.push(`${indent}${key}: ${value}`); - } - continue; - } - - // Listenelement (- ...) - if (trimmed.startsWith('- ') || trimmed === '-') { - out.push(`${indent}${trimmed}`); - continue; - } - - // Alles andere - out.push(`${indent}${trimmed}`); - } - - return out.join('\n'); -} - -// Berechnet die logische Einrückungstiefe (2 Spaces pro Level) -function detectDepth(line) { - const spaces = line.search(/\S/); - if (spaces < 0) return 0; - return Math.round(spaces / 2); -} - -// Findet den Doppelpunkt eines Keys (nicht innerhalb von Strings) -function findKeyColon(trimmed) { - let inSingle = false, inDouble = false; - for (let i = 0; i < trimmed.length; i++) { - const c = trimmed[i]; - if (c === "'" && !inDouble) inSingle = !inSingle; - if (c === '"' && !inSingle) inDouble = !inDouble; - if (c === ':' && !inSingle && !inDouble) { - // Muss von Leerzeichen oder End gefolgt sein - const next = trimmed[i+1]; - if (next === undefined || next === ' ' || next === '\t') return i; - } - } - return -1; -} - -function YamlTool() { - const [input, setInput] = useState(''); - const [output, setOutput] = useState(''); - const [error, setError] = useState(''); - const [copied, setCopied] = useState(false); - const outputRef = useRef(null); - - const format = () => { - if (!input.trim()) { setError('Bitte YAML eingeben.'); setOutput(''); return; } - try { - const result = parseAndFormatYaml(input); - setOutput(result); - setError(''); - } catch(e) { - setError('Fehler beim Formatieren: ' + e.message); - setOutput(''); - } + 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 null; + if (v === undefined) return ~; + if (typeof v === 'boolean') return {String(v)}; + if (typeof v === 'number') return {v}; + 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 {needsQuotes ? `"${s.replace(/"/g,'\\"')}"` : s}; + }; + + // Leaf: key: value + if (isLeaf) { + return ( +
+ {/* Vertical indent lines */} + {Array.from({length:depth},(_,i)=>( +
+ ))} +
+ {keyName!==null&&{keyName}: } + {renderValue(data)} +
+
+ ); + } + + const count = isObj ? Object.keys(data).length : data.length; + const summary = isObj ? `{${count}}` : `[${count}]`; + + return ( +
+ {/* Vertical indent lines for this level */} + {Array.from({length:depth},(_,i)=>( +
+ ))} + + {/* Header row */} +
hasChildren&&onToggle(nodeKey)}> + {hasChildren&&( + + )} + + {keyName!==null&&{keyName}: } + {isCollapsed&&{summary}} + {!isCollapsed&&isArr&&# {count} Einträge} + +
+ + {/* Children */} + {!isCollapsed && ( +
+ {isObj && Object.entries(data).map(([k,v],i)=>( + + ))} + {isArr && data.map((v,i)=>( +
+
+
+ - + {typeof v === 'object' && v !== null + ? + :
+ {v===null?null:typeof v==='boolean'?{String(v)}:typeof v==='number'?{v}:{String(v)}} +
+ } +
+
+ ))} +
+ )} +
+ ); +} + +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 Tool ───────────────────────────────────────────────────────────────── +function YamlTool({ mobile }) { + const [input, setInput] = useState(''); + const [parsed, setParsed] = useState(null); + const [formatted,setFormatted]= useState(''); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(false); + const [view, setView] = useState('tree'); // 'tree' | 'raw' + const [collapsed,setCollapsed]= useState(new Set()); + + const process = useCallback((text) => { + if (!text.trim()) { setParsed(null); setFormatted(''); setError(''); return; } + try { + const obj = jsyaml.load(text); + setParsed(obj); + setFormatted(jsyaml.dump(obj, { indent:2, lineWidth:-1, noRefs:true })); + setError(''); + setCollapsed(new Set()); // reset collapsed + } catch(e) { + setError(e.message); + setParsed(null); setFormatted(''); + } + },[]); + + const onInput = v => { setInput(v); process(v); }; + const copy = async () => { - try { - await navigator.clipboard.writeText(output); - setCopied(true); - setTimeout(()=>setCopied(false), 1500); - } catch { } + try { await navigator.clipboard.writeText(formatted); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {} }; - const clear = () => { setInput(''); setOutput(''); setError(''); }; - - // Auto-format on paste - const onPaste = e => { - setTimeout(()=>{ - const val = e.target.value; - if (val.trim()) { - const result = parseAndFormatYaml(val); - setOutput(result); - setError(''); - } - }, 0); + 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 (
-
+
{/* Input */}
- {input && } + {input&&}
-