feat(ui): add CodeBlock and CodeEditor components with Monaco and hljs

- add CodeBlock for read-only syntax-highlighted display (hljs, js/json)
- add CodeEditor wrapping Monaco editor with theme sync, focus state, and error state
- replace code textareas in snippet, credential, and step pages with CodeEditor
- replace code <pre> blocks in snippet and credential detail pages with CodeBlock
- make form cards full-width on pages with code editors
- add resize:vertical support to CodeEditor wrapper with automaticLayout
This commit is contained in:
2026-04-10 15:53:26 +03:00
parent 2046e64428
commit 1c13e068ad
21 changed files with 391 additions and 76 deletions
@@ -0,0 +1,18 @@
.wrapper {
width: 100%;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
resize: vertical;
min-height: 80px;
transition: border-color var(--transition), box-shadow var(--transition);
}
.focused {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.hasError {
border-color: var(--color-danger);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import styles from './CodeEditor.module.css';
export interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language?: string;
rows?: number;
error?: boolean;
}
function useMonacoTheme() {
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light'
);
useEffect(() => {
const observer = new MutationObserver(() => {
setTheme(
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light'
);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
return theme;
}
export function CodeEditor({
value,
onChange,
language = 'javascript',
rows = 6,
error = false,
}: CodeEditorProps) {
const theme = useMonacoTheme();
const [focused, setFocused] = useState(false);
const height = rows * 20 + 16;
const handleMount: OnMount = (editor) => {
editor.onDidFocusEditorWidget(() => setFocused(true));
editor.onDidBlurEditorWidget(() => setFocused(false));
};
return (
<div
className={[
styles.wrapper,
focused ? styles.focused : '',
error ? styles.hasError : '',
]
.filter(Boolean)
.join(' ')}
style={{ height }}
>
<Editor
height="100%"
theme={theme}
language={language}
value={value}
onChange={(v) => onChange(v ?? '')}
onMount={handleMount}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineHeight: 20,
wordWrap: 'on',
padding: { top: 8, bottom: 8 },
folding: false,
renderLineHighlight: 'line',
scrollbar: { vertical: 'auto', horizontal: 'hidden' },
automaticLayout: true,
}}
/>
</div>
);
}