Dev-Tools: YAML raus, Crontab-Generator rein (Erklären + Erstellen)
This commit is contained in:
@@ -7,7 +7,6 @@
|
|||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
@@ -15,4 +14,4 @@
|
|||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,330 +1,269 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { S } from '../lib.js';
|
import { S } from '../lib.js';
|
||||||
import jsyaml from 'js-yaml';
|
|
||||||
|
|
||||||
// ── YAML Tree Node (collapsible, mit Einrück-Linien) ─────────────────────────
|
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
function YamlNode({ data, depth = 0, keyName = null, collapsed, onToggle, path }) {
|
const WEEKDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||||
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;
|
function parseField(val) {
|
||||||
const isCollapsed = collapsed.has(nodeKey);
|
val = String(val).trim();
|
||||||
const hasChildren = isObj || isArr;
|
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};
|
||||||
|
}
|
||||||
|
|
||||||
const keyColor = '#7ecaff';
|
function describeField(f, names) {
|
||||||
const strColor = '#ce9178';
|
switch(f.type) {
|
||||||
const numColor = '#b5cea8';
|
case 'all': return null;
|
||||||
const boolColor = '#569cd6';
|
case 'step': return `alle ${f.step}`;
|
||||||
const nullColor = '#808080';
|
case 'stepFrom': return `ab ${f.from} alle ${f.step}`;
|
||||||
const indentColor= 'rgba(255,255,255,0.08)';
|
case 'list': return f.vals.map(v=>names?.[v]||v).join(', ');
|
||||||
|
case 'range': return `${names?.[f.from]||f.from}–${names?.[f.to]||f.to}`;
|
||||||
const valueColor = (v) => {
|
case 'single': return names?.[f.val] || String(f.val);
|
||||||
if (v === null || v === undefined) return nullColor;
|
default: return f.val;
|
||||||
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;
|
function explainCron(expr) {
|
||||||
const summary = isObj ? `{${count}}` : `[${count}]`;
|
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 (
|
return (
|
||||||
<div style={{position:'relative'}}>
|
<div>
|
||||||
{/* Vertical indent lines for this level */}
|
<div style={{marginBottom:10}}>
|
||||||
{Array.from({length:depth},(_,i)=>(
|
<label style={{...S.head,display:'block',marginBottom:6,fontSize:10}}>CRONTAB AUSDRUCK</label>
|
||||||
<div key={i} style={{position:'absolute',left:i*18+6,top:0,bottom:0,
|
<input value={input} onChange={e=>setInput(e.target.value)} placeholder="z.B. 0 8 * * 1"
|
||||||
borderLeft:`1px solid ${indentColor}`,pointerEvents:'none'}}/>
|
autoCapitalize="none" style={{...S.inp,fontFamily:"'Courier New',monospace",fontSize:16}}/>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
|
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:16}}>
|
||||||
{/* Children */}
|
{EXAMPLES.map(([ex,l])=>(
|
||||||
{!isCollapsed && (
|
<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.25)',fontSize:9}}>({l})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{result && (result.ok ? (
|
||||||
<div>
|
<div>
|
||||||
{isObj && Object.entries(data).map(([k,v],i)=>(
|
<div style={{background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
|
||||||
<YamlNode key={k} data={v} depth={depth+1} keyName={k}
|
borderRadius:10,padding:'14px 16px',marginBottom:12}}>
|
||||||
collapsed={collapsed} onToggle={onToggle} path={`${path}.${k}`}/>
|
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginBottom:5}}>BEDEUTET</div>
|
||||||
))}
|
<div style={{color:'#fff',fontFamily:'monospace',fontSize:16,fontWeight:700}}>{result.human}</div>
|
||||||
{isArr && data.map((v,i)=>(
|
</div>
|
||||||
<div key={i} style={{position:'relative'}}>
|
{result.fields && (
|
||||||
<div style={{position:'absolute',left:(depth)*18+6,top:10,width:8,
|
<div style={{display:'grid',gridTemplateColumns:'repeat(5,1fr)',gap:8,marginBottom:12}}>
|
||||||
borderTop:`1px solid ${indentColor}`}}/>
|
{[['MIN',0],[' STD',1],['TAG',2],['MON',3],['WTAG',4]].map(([lbl,i])=>{
|
||||||
<div style={{paddingLeft:(depth+1)*18,minHeight:20,display:'flex',alignItems:'baseline'}}>
|
const raw=expr.split(/\s+/)[i];
|
||||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:"'Courier New',monospace",fontSize:12,
|
const fNames=i===3?MONTHS:i===4?WEEKDAYS:null;
|
||||||
position:'absolute',left:(depth)*18+8}}>-</span>
|
const fd=Object.values(result.fields)[i];
|
||||||
{typeof v === 'object' && v !== null
|
return (
|
||||||
? <YamlNode data={v} depth={depth+1} keyName={null}
|
<div key={lbl} style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.07)',
|
||||||
collapsed={collapsed} onToggle={onToggle} path={`${path}[${i}]`}/>
|
borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
|
||||||
: <div style={{fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7}}>
|
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>{lbl}</div>
|
||||||
{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 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>
|
||||||
</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>
|
||||||
)}
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectPaths(data, path='root', paths=[]) {
|
// ── Builder ───────────────────────────────────────────────────────────────────
|
||||||
if (data !== null && typeof data === 'object') {
|
function CronBuilder() {
|
||||||
paths.push(path);
|
const [minute, setMinute] = useState('0');
|
||||||
if (Array.isArray(data)) data.forEach((_,i)=>collectPaths(data[i],`${path}[${i}]`,paths));
|
const [hour, setHour] = useState('8');
|
||||||
else Object.entries(data).forEach(([k,v])=>collectPaths(v,`${path}.${k}`,paths));
|
const [dom, setDom] = useState('*');
|
||||||
}
|
const [month, setMonth] = useState('*');
|
||||||
return paths;
|
const [dow, setDow] = useState('*');
|
||||||
}
|
const [preset, setPreset] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// ── YAML Auto-Fix Stufe 1: Einheits-Normalisierung (kleine Fehler) ────────────
|
const cron = `${minute} ${hour} ${dom} ${month} ${dow}`;
|
||||||
function fixByUnit(text) {
|
const result = explainCron(cron);
|
||||||
const lines = text.split('\n').map(l => l.replace(/\t/g, ' '));
|
const runs = result?.ok ? nextRuns(cron) : [];
|
||||||
const indents = lines
|
const fmtDt = d => d.toLocaleString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
|
||||||
.filter(l => l.trim() && !l.trim().startsWith('#'))
|
|
||||||
.map(l => l.search(/\S/))
|
|
||||||
.filter(n => n > 0);
|
|
||||||
if (!indents.length) return text;
|
|
||||||
const unit = Math.min(...indents);
|
|
||||||
if (unit === 2) return lines.join('\n'); // bereits 2-space
|
|
||||||
return lines.map(line => {
|
|
||||||
if (!line.trim()) return '';
|
|
||||||
if (line.trim().startsWith('#')) return line.trimEnd();
|
|
||||||
const spaces = line.search(/\S/);
|
|
||||||
if (spaces <= 0) return line.trimEnd();
|
|
||||||
return ' '.repeat(Math.round(spaces / unit)) + line.trimStart().trimEnd();
|
|
||||||
}).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── YAML Auto-Fix Stufe 2: Stack-basiert (starke Schäden) ────────────────────
|
const copy = async () => { try { await navigator.clipboard.writeText(cron); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {} };
|
||||||
function fixByStack(text) {
|
|
||||||
const lines = text.split('\n').map(l => l.replace(/\t/g, ' '));
|
|
||||||
const stack = [{ indent: -1, depth: -1 }];
|
|
||||||
const result = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) { result.push(''); continue; }
|
|
||||||
const indent = line.search(/\S/);
|
|
||||||
const content = line.trim();
|
|
||||||
if (content.startsWith('#')) {
|
|
||||||
result.push(' '.repeat(Math.max(0, stack[stack.length-1].depth + 1)) + content);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
while (stack.length > 1 && stack[stack.length-1].indent >= indent) stack.pop();
|
|
||||||
const depth = stack[stack.length-1].depth + 1;
|
|
||||||
stack.push({ indent, depth });
|
|
||||||
result.push(' '.repeat(depth) + content);
|
|
||||||
}
|
|
||||||
return result.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortError(e) {
|
const PRESETS = [
|
||||||
const first = e.message.split('\n')[0];
|
{k:'every_min', l:'Jede Minute', v:['*','*','*','*','*']},
|
||||||
return first.length > 120 ? first.slice(0, 120) + '…' : first;
|
{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','*','*','*']},
|
||||||
|
];
|
||||||
|
|
||||||
// ── YAML Tool ─────────────────────────────────────────────────────────────────
|
const applyPreset = p => {
|
||||||
function YamlTool({ mobile }) {
|
setPreset(p.k);
|
||||||
const [input, setInput] = useState('');
|
const [mi,hr,dm,mo,dw] = p.v;
|
||||||
const [parsed, setParsed] = useState(null);
|
setMinute(mi); setHour(hr); setDom(dm); setMonth(mo); setDow(dw);
|
||||||
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; }
|
|
||||||
|
|
||||||
const tryParse = (t) => { try { return { obj: jsyaml.load(t), err: null }; } catch(e) { return { obj: null, err: e }; } };
|
|
||||||
|
|
||||||
// Stufe 1: direkt
|
|
||||||
const r1 = tryParse(text);
|
|
||||||
if (!r1.err) {
|
|
||||||
setParsed(r1.obj); setFormatted(jsyaml.dump(r1.obj, {indent:2,lineWidth:-1,noRefs:true}));
|
|
||||||
setError(''); setWarning(''); setCollapsed(new Set()); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stufe 2: Unit-Normalisierung (für kleine Fehler wie 4-space statt 2-space)
|
|
||||||
const fixed1 = fixByUnit(text);
|
|
||||||
const r2 = tryParse(fixed1);
|
|
||||||
if (!r2.err) {
|
|
||||||
setParsed(r2.obj); setFormatted(jsyaml.dump(r2.obj, {indent:2,lineWidth:-1,noRefs:true}));
|
|
||||||
setWarning('✓ Einrückung normalisiert'); setError(''); setCollapsed(new Set()); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stufe 3: Stack-Fix (für stärker beschädigte Struktur)
|
|
||||||
const fixed2 = fixByStack(text);
|
|
||||||
const r3 = tryParse(fixed2);
|
|
||||||
if (!r3.err) {
|
|
||||||
setParsed(r3.obj); setFormatted(jsyaml.dump(r3.obj, {indent:2,lineWidth:-1,noRefs:true}));
|
|
||||||
setWarning('⚠ Einrückung stark korrigiert – bitte Ergebnis prüfen'); setError(''); setCollapsed(new Set()); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stufe 4: best-effort – zeige korrigierten Text zum manuellen Nachbessern
|
|
||||||
setError(shortError(r1.err));
|
|
||||||
setWarning('Struktur nicht automatisch reparierbar – normalisierter Text zum manuellen Korrigieren:');
|
|
||||||
setParsed(null);
|
|
||||||
setFormatted(fixed2);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 => {
|
const Lbl = ({c}) => <div style={{...S.head,marginBottom:4,fontSize:9}}>{c}</div>;
|
||||||
setCollapsed(prev => {
|
const Sel = ({value,onChange,children}) => (
|
||||||
const n = new Set(prev);
|
<select value={value} onChange={e=>{onChange(e.target.value);setPreset('');}}
|
||||||
n.has(path) ? n.delete(path) : n.add(path);
|
style={{...S.inp,fontSize:12,padding:'6px 8px',width:'100%'}}>{children}</select>
|
||||||
return n;
|
);
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapseAll = () => { if(parsed) setCollapsed(new Set(collectPaths(parsed))); };
|
|
||||||
const expandAll = () => setCollapsed(new Set());
|
|
||||||
|
|
||||||
const colCols = mobile ? '1fr' : '1fr 1fr';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{display:'grid',gridTemplateColumns:colCols,gap:12,marginBottom:10}}>
|
<div style={{marginBottom:14}}>
|
||||||
{/* Input */}
|
<Lbl c="SCHNELLAUSWAHL"/>
|
||||||
<div>
|
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
||||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
|
{PRESETS.map(p=>(
|
||||||
<label style={{...S.head,marginBottom:0,fontSize:10}}>EINGABE</label>
|
<button key={p.k} onClick={()=>applyPreset(p)} style={{
|
||||||
{input&&<button onClick={()=>{setInput('');setParsed(null);setFormatted('');setError('');}}
|
padding:'5px 11px',borderRadius:6,fontFamily:'monospace',fontSize:10,cursor:'pointer',
|
||||||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.3)',cursor:'pointer',fontFamily:'monospace',fontSize:10}}>✕</button>}
|
background:preset===p.k?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.05)',
|
||||||
</div>
|
border:`1px solid ${preset===p.k?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||||
<textarea value={input} onChange={e=>onInput(e.target.value)}
|
color:preset===p.k?'#4ecdc4':'rgba(255,255,255,0.55)',
|
||||||
placeholder={'name: Beispiel\nversion: 1.0\nkonfiguration:\n host: localhost\n port: 8080\nliste:\n - item1\n - item2'}
|
}}>{p.l}</button>
|
||||||
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 && !formatted ? (
|
|
||||||
<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: error ? 'rgba(255,230,109,0.07)' : 'rgba(78,205,196,0.07)',
|
|
||||||
border:`1px solid ${error ? 'rgba(255,230,109,0.25)' : 'rgba(78,205,196,0.2)'}`,
|
|
||||||
borderRadius:7,padding:'7px 12px',
|
|
||||||
color: error ? '#ffe66d' : '#4ecdc4',
|
|
||||||
fontFamily:'monospace',fontSize:10,lineHeight:1.6,marginBottom:8}}>
|
|
||||||
{error ? '⚠ ' : '✓ '}{warning}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && formatted && (
|
|
||||||
<div style={{background:'rgba(255,107,157,0.06)',border:'1px solid rgba(255,107,157,0.15)',
|
|
||||||
borderRadius:7,padding:'6px 12px',color:'rgba(255,107,157,0.7)',
|
|
||||||
fontFamily:'monospace',fontSize:9,lineHeight:1.6,marginBottom:8}}>
|
|
||||||
⚠ {error}
|
|
||||||
</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>
|
</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 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -332,50 +271,63 @@ function YamlTool({ mobile }) {
|
|||||||
|
|
||||||
// ── Tool-Definitionen ─────────────────────────────────────────────────────────
|
// ── Tool-Definitionen ─────────────────────────────────────────────────────────
|
||||||
const TOOL_DEFS = [
|
const TOOL_DEFS = [
|
||||||
{ id:'yaml', label:'📄 YAML Formatter', desc:'YAML korrekt einrücken und formatieren', ready:true, component:YamlTool },
|
{id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true,
|
||||||
{ id:'json', label:'{ } JSON Formatter', desc:'JSON formatieren und validieren', ready:false },
|
tabs:[{id:'explain',label:'Erklären'},{id:'build',label:'Erstellen'}],
|
||||||
{ id:'base64', label:'🔐 Base64', desc:'Text ↔ Base64 kodieren/dekodieren', ready:false },
|
components:{explain:CronExplainer,build:CronBuilder}},
|
||||||
{ id:'regex', label:'🔍 Regex Tester', desc:'Reguläre Ausdrücke live testen', ready:false },
|
{id:'json', label:'{ } JSON', desc:'JSON formatieren', ready:false},
|
||||||
{ id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:false },
|
{id:'base64', label:'🔐 Base64', desc:'Text ↔ Base64', ready:false},
|
||||||
{ id:'jwt', label:'🔑 JWT Decoder', desc:'JSON Web Tokens dekodieren', ready:false },
|
{id:'regex', label:'🔍 Regex', desc:'Regex testen', ready:false},
|
||||||
{ id:'timestamp', label:'🕐 Timestamp', desc:'Unix Timestamps umrechnen', ready:false },
|
{id:'diff', label:'± Text Diff', desc:'Texte vergleichen', ready:false},
|
||||||
{ id:'color', label:'🎨 Farben', desc:'HEX ↔ RGB ↔ HSL umrechnen', ready:false },
|
{id:'jwt', label:'🔑 JWT', desc:'JWT 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 }) {
|
export default function DevTools({ toast, mobile }) {
|
||||||
const [activeTool, setActiveTool] = useState('yaml');
|
const [activeTool, setActiveTool] = useState('cron');
|
||||||
|
const [activeTab, setActiveTab] = useState('explain');
|
||||||
const active = TOOL_DEFS.find(t=>t.id===activeTool);
|
const active = TOOL_DEFS.find(t=>t.id===activeTool);
|
||||||
|
const ActiveComp = active?.tabs ? active.components[activeTab] : active?.component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:1100}}>
|
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:20,flexWrap:'wrap'}}>
|
<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>
|
<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>
|
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10}}>Konverter · Formatter · Helfer</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{display:'flex',gap:7,flexWrap:'wrap',marginBottom:20}}>
|
||||||
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:20}}>
|
|
||||||
{TOOL_DEFS.map(t=>(
|
{TOOL_DEFS.map(t=>(
|
||||||
<button key={t.id}
|
<button key={t.id} title={t.desc}
|
||||||
onClick={()=>t.ready ? setActiveTool(t.id) : toast('Kommt bald 🚧','error')}
|
onClick={()=>{ if(t.ready){setActiveTool(t.id);setActiveTab(t.tabs?.[0]?.id||'');} else toast('Kommt bald 🚧'); }}
|
||||||
title={t.desc}
|
style={{padding:'8px 14px',borderRadius:10,fontFamily:'monospace',fontSize:11,cursor:t.ready?'pointer':'default',
|
||||||
style={{
|
background:activeTool===t.id?'#4ecdc4':t.ready?'rgba(255,255,255,0.06)':'rgba(255,255,255,0.02)',
|
||||||
padding:'8px 14px',borderRadius:10,fontFamily:'monospace',fontSize:11,cursor:t.ready?'pointer':'default',
|
color: activeTool===t.id?'#0d0d0f':t.ready?'rgba(255,255,255,0.65)':'rgba(255,255,255,0.2)',
|
||||||
background: activeTool===t.id?'#4ecdc4':t.ready?'rgba(255,255,255,0.06)':'rgba(255,255,255,0.02)',
|
border: activeTool===t.id?'none':t.ready?'1px solid rgba(255,255,255,0.1)':'1px solid rgba(255,255,255,0.05)',
|
||||||
color: activeTool===t.id?'#0d0d0f':t.ready?'rgba(255,255,255,0.65)':'rgba(255,255,255,0.2)',
|
fontWeight: activeTool===t.id?700:400}}>
|
||||||
border: activeTool===t.id?'none':t.ready?'1px solid rgba(255,255,255,0.1)':'1px solid rgba(255,255,255,0.05)',
|
{t.label}{!t.ready&&<span style={{marginLeft:4,fontSize:8,opacity:0.4}}>bald</span>}
|
||||||
fontWeight: activeTool===t.id?700:400,
|
|
||||||
}}>
|
|
||||||
{t.label}{!t.ready&&<span style={{marginLeft:4,fontSize:8,opacity:0.5}}>bald</span>}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{...S.card}}>
|
<div style={{...S.card}}>
|
||||||
<div style={{display:'flex',alignItems:'baseline',gap:10,marginBottom:14}}>
|
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:14,flexWrap:'wrap'}}>
|
||||||
<div style={{...S.head,marginBottom:0}}>{active?.label}</div>
|
<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>
|
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10}}>{active?.desc}</span>
|
||||||
</div>
|
</div>
|
||||||
{active?.component && <active.component toast={toast} mobile={mobile}/>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user