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
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { snippets } from '../../api';
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import styles from '../Page.module.css';
export function CreateSnippetPage() {
@@ -59,7 +59,7 @@ export function CreateSnippetPage() {
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCard}>
<Card className={styles.formCardFull}>
<div className={styles.formFields}>
<Input
label={t('snippets.form_name')}
@@ -84,16 +84,14 @@ export function CreateSnippetPage() {
{t('snippets.form_code')}
<span className={styles.requiredMark}> *</span>
</label>
<textarea
className={[styles.textarea, codeError ? styles.textareaError : ''].filter(Boolean).join(' ')}
rows={14}
<CodeEditor
value={code}
onChange={(e) => {
setCode(e.target.value);
onChange={(v) => {
setCode(v);
setCodeError('');
}}
placeholder={t('snippets.form_code_placeholder')}
spellCheck={false}
rows={14}
error={!!codeError}
/>
{codeError && <span className={styles.fieldError}>{codeError}</span>}
</div>
+7 -9
View File
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { snippets } from '../../api';
import type { Snippet } from '../../api';
import { Breadcrumbs, Button, Card, Input } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import styles from '../Page.module.css';
export function EditSnippetPage() {
@@ -83,7 +83,7 @@ export function EditSnippetPage() {
{!loading && snippet && (
<form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCard}>
<Card className={styles.formCardFull}>
<div className={styles.formFields}>
<Input
label={t('snippets.form_name')}
@@ -108,16 +108,14 @@ export function EditSnippetPage() {
{t('snippets.form_code')}
<span className={styles.requiredMark}> *</span>
</label>
<textarea
className={[styles.textarea, codeError ? styles.textareaError : ''].filter(Boolean).join(' ')}
rows={14}
<CodeEditor
value={code}
onChange={(e) => {
setCode(e.target.value);
onChange={(v) => {
setCode(v);
setCodeError('');
}}
placeholder={t('snippets.form_code_placeholder')}
spellCheck={false}
rows={14}
error={!!codeError}
/>
{codeError && <span className={styles.fieldError}>{codeError}</span>}
</div>
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Download, Pencil, Trash2 } from 'lucide-react';
import { Upload, Pencil, Trash2 } from 'lucide-react';
import { CodeBlock } from '../../ui';
import { stringify as yamlStringify } from 'yaml';
import { snippets } from '../../api';
import type { Snippet } from '../../api';
@@ -55,7 +56,7 @@ export function SnippetDetailPage() {
{snippet && (
<div className={styles.toolbarActions}>
<Button variant="secondary" size="sm" onClick={handleExport}>
<Download size={14} />
<Upload size={14} />
{t('snippets.action_export')}
</Button>
<Button
@@ -114,7 +115,7 @@ export function SnippetDetailPage() {
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
</div>
<Card>
<pre className={styles.codeBlock}>{snippet.code}</pre>
<CodeBlock code={snippet.code} />
</Card>
</div>
</>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Code, Pencil, Trash2, Plus, Upload } from 'lucide-react';
import { Code, Pencil, Trash2, Plus, Download } from 'lucide-react';
import { parse as yamlParse } from 'yaml';
import { snippets } from '../../api';
import type { Snippet } from '../../api';
@@ -134,7 +134,7 @@ export function SnippetsPage() {
onChange={handleImport}
/>
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload size={14} />
<Download size={14} />
{t('snippets.action_import')}
</Button>
</div>